前言
上一篇文章说了侧滑按钮的实现,可谓是非常精彩,尤其是判断触摸事件的状态以及细节处理,本章来说一下侧滑删除和拖拽功能,这里看起来效果更复杂,但是实现起来却简单很多。
上一篇侧滑按钮的地址:
juejin.cn/post/700762…
因为RecyclerView有一个类专门是处理侧滑删除和拖拽功能的,这个类就是:ItemTouchHelper,这个类的注释是:
This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.
It works with a RecyclerView and a Callback class,
which configures what type of interactions are enabled
and also receives events when user performs these actions.
专门用来实现滑动删除和拖拽的工具类,同时它需要一个RecyclerView和Callback,用来配置哪些触摸操作是激活的,同时当执行这些操作需要干些什么。
既然Android系统有代码为我们实现这些功能,那我们话不多说,直接开整。
源项目github地址: github.com/angcyo/DslA…
正文
首先定义一个拖拽帮助类:
class DragCallbackHelper : ItemTouchHelper.Callback()
这个Callback上面也说了,主要是应用想做哪些操作通过这个类的回调告诉ItemTouchHelper类,依次看一下这些方法回调。
getMovementFlags
方法原型:
public abstract int getMovementFlags(@NonNull RecyclerView recyclerView,
@NonNull ViewHolder viewHolder);
注释:
Should return a composite flag which defines the enabled move directions
in each state (idle, swiping, dragging).
这里说应该返回一个组合的flag用来表示哪些方向的move是可行的在不同的状态(idle,滑动,拖拽)。
咋一看还需要为每个状态都设置不同的方向还比较复杂,其实系统早就为我们想好了,我们只需要执行一个方法,同样在注释里有说:
Instead of composing this flag manually,
you can use makeMovementFlags(int, int) or makeFlag(int, int).
这里你只需要执行makeMovementFlags方法,然后把这个方法的返回值当成getMovementFlags的返回值即可,不用自己拼接,makeMovementFlags原型:
public static int makeMovementFlags(int dragFlags, int swipeFlags) {
return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags)
| makeFlag(ACTION_STATE_SWIPE, swipeFlags)
| makeFlag(ACTION_STATE_DRAG, dragFlags);
}
到这里我们便只需要凑齐dragFlags和swipeFlags2个参数即可,分别表示拖拽激活的方向和侧滑激活的方向,那如何得到我们想要的参数呢?
比如我现在想实现4个方向的拖拽和左右的侧滑,那就先看方向的定义,这个是在TouchHelper中定义:
public static final int UP = 1;
public static final int DOWN = 1 << 1;
public static final int LEFT = 1 << 2;
public static final int RIGHT = 1 << 3;
其实也就是为了进行与运算才这样定义,那全方向就是:
const val FLAG_ALL = ItemTouchHelper.LEFT or
ItemTouchHelper.RIGHT or
ItemTouchHelper.DOWN or
ItemTouchHelper.UP
水平方向就是:
const val FLAG_HORIZONTAL = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
好了,那我们来看一下getMovementFlags中的实现代码:
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
//拿到这个viewHolder对应的Item数据
val dslAdapterItem = _dslAdapter?.getItemData(viewHolder.adapterPosition)
return dslAdapterItem?.run {
//看item自己有没有配置dragFlag,否则使用DragCallbackHelp默认配置
val dFlag =
if (itemDragFlag >= 0) itemDragFlag else this@DragCallbackHelper.itemDragFlag
//看item自己有没有配置swipeFlag,否则使用DragCallbackHelp默认配置
val sFlag =
if (itemSwipeFlag >= 0) itemSwipeFlag else this@DragCallbackHelper.itemSwipeFlag
//调用makeMovementFlags方法来返回flag值
makeMovementFlags(
if (itemDragEnable) dFlag else FLAG_NONE,
if (itemSwipeEnable) sFlag else FLAG_NONE
)
} ?: FLAG_NONE
}
当配置了这个后,只需要进行以下代码把ItemTouchHelper绑定到一个RecyclerView即可:
fun attachToRecyclerView(recyclerView: RecyclerView) {
_recyclerView = recyclerView
_itemTouchHelper = _itemTouchHelper ?: ItemTouchHelper(this)
_itemTouchHelper?.attachToRecyclerView(recyclerView)
}
这时便可以拖拽或者滑动Item了,完全由ItemTouchHelper帮我们完成,当然这里有很多回调方法会执行,以达到自定义的目的,下面挨个介绍。
onSelectedChanged
方法原型:
public void onSelectedChanged(@Nullable ViewHolder viewHolder, int actionState) {
if (viewHolder != null) {
ItemTouchUIUtilImpl.INSTANCE.onSelected(viewHolder.itemView);
}
}
注释:
Called when the ViewHolder swiped or dragged by the ItemTouchHelper is changed
If you override this method, you should call super.
Params:
viewHolder – The new ViewHolder that is being swiped or dragged. Might be null if it is cleared.
actionState – One of ACTION_STATE_IDLE, ACTION_STATE_SWIPE or ACTION_STATE_DRAG.
当进行拖拽时,这个是第一个回调的方法,表示这个viewHolder发生了变化,当需要重写这个方法时,必须调用super。
其中actionState一共有3种状态,比如拖拽一个ViewHolder,它的状态变化是:
ACTION_STATE_DRAG -> ACTION_STATE_IDLE,
所以我们可以在这个方法里执行一些回调,比如拖拽删除,就可以当这个方法回调DRAG时显示出删除View(仿微信发朋友圈)即可。
这个方法回调后,ViewHolder会在手指拖拽中进行位移,这时就会立马回调出该ViewHolder的位移信息,也就是onChildDraw方法。
onChildDraw
方法原型:
public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView,
@NonNull ViewHolder viewHolder,
float dX, float dY, int actionState, boolean isCurrentlyActive) {
ItemTouchUIUtilImpl.INSTANCE.onDraw(c, recyclerView, viewHolder.itemView, dX, dY,
actionState, isCurrentlyActive);
}
注释:
Called by ItemTouchHelper on RecyclerView's onDraw callback.
If you would like to customize how your View's respond to user interactions,
this is a good place to override.
Default implementation translates the child by the given dX, dY. ItemTouchHelper also takes care of drawing the child after other children if it is being dragged. This is done using child re-ordering mechanism. On platforms prior to L, this is achieved via android.view.ViewGroup.getChildDrawingOrder(int, int) and on L and after, it changes View's elevation value to be greater than all other children.)
调用时机:在RecyclerView进行绘制时回调这个方法。
作用:当拖拽时或者滑动时,想根据手势自定义View的响应时。
介绍:默认的实现就是对子View即拖拽的ViewHolder进行位移,通过dX和dY。同时ItemTouchHelper还负责拖拽后绘制这个子对象,这个是通过修改ViewGroup的子View绘制顺序来实现的,也就相当于把被拖拽的View的z轴至高,这个后面有机会细说。
到这里我们就可以做一些效果了,比如最常见的是当侧滑删除时,在被删除的那个ViwHolder位置上显示一个文本,来看一下代码:
override fun onChildDraw(
canvas: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
//当是滑动状态且需要显示swipeTip时
if (enableSwipeTip && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
//需要被侧滑删除的View
val itemView = viewHolder.itemView
//x表示需要绘制swipeTipText的x坐标
val x: Float = if (dX > 0) {
//向右滑动删除
itemView.left.toFloat()
} else {
//向左滑动删除
(itemView.right - _drawText._paint.measureText(swipeTipText.toString()))
}
//y表示需要绘制swipeTipText的y坐标
val y: Float =
itemView.top + itemView.measuredHeight / 2 - _drawText._paint.textHeight() / 2
//先保存画布
canvas.save()
//位移画布
canvas.translate(x, y)
//在被滑动的View下面绘制Text
canvas.drawText(swipeTipText.toString(),0f,0f,_paint)
canvas.restore()
}
}
看一下效果:
这里有个canvas的操作,为什么要频繁的save、translate和restore呢,原因是这个canvas是RecyclerView的canvas,也就是这个画布的宽高是RecyclerView的宽高,所以就需要对画布进行平移到需要的位置再进行绘制,当然我不位移,直接在绘制Text加上坐标也可以:
canvas.drawText(swipeTipText.toString(),x,y,_paint)
这样可以同样实现效果。
所以这个函数的关键是根据拖拽或者滑动通过canvas来绘制一些效果,其中获取当前itemView的位置是关键以及绘制的位置是关键。
既然,现在可以拖拽或者滑动了,同时在滑动时还可以自己绘制View,那拖拽或者滑动结束肯定有回调,先说滑动删除后的回调:onSwiped方法。
onSwiped
方法原型:
public abstract void onSwiped(@NonNull ViewHolder viewHolder, int direction);
注释:
Called when a ViewHolder is swiped by the user.
If you are returning relative directions (START , END)
from the getMovementFlags(RecyclerView, RecyclerView.ViewHolder) method,
this method will also use relative directions. Otherwise,
it will use absolute directions.
If you don't support swiping, this method will never be called.
调用时机:当滑动发生时,注意是滑动,不是拖拽。
注意点:这里的操作是ItemTouchHelper这个类帮我们做的,比如侧滑符合Fling,就认为是需要删除,这时就会把这个子View删除,但是不会删除数据,只是在显示层的效果,我们看一下:
所以就需要在Swipe的回调后进行处理,也就是删除这个item的数据,同时刷新界面。
看一下代码:
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
Log.i(TAG, "onSwiped: direction = $direction")
_swipeHappened = true
//拿到item进行删除
_dslAdapter?.apply {
getItemData(viewHolder.adapterPosition)?.apply {
//删除数据同时,调用notify
removeItem(this)
//item删除回调
onItemSwipeDeleted?.invoke(this)
}
}
}
这样在删除后就可以迅速重新绘制,效果如下:
好了,滑动删除说完,再说一下拖拽,对于拖拽重新排序,这个也是ItemTouchHelper帮我们实现好了,这里会涉及到onMove函数。
onMove
方法原型:
public abstract boolean onMove(@NonNull RecyclerView recyclerView,
@NonNull ViewHolder viewHolder, @NonNull ViewHolder target);
注释:
Called when ItemTouchHelper wants to move the dragged item from its old position to the new position.
这里说当你拖拽一个ViewHolder时,ItemTouchHelper会自动帮你处理,当它觉得可以重新排序且交换位置时,这个方法会回调,当达不到这个要求时,方法不会回调,这里还是看一下效果:
这里会发现在拖拽到一定位置时,会认为可以发生交互则会发生交互且重新排序,注意这里每交互一次,就会回调一次moMove,比如这gif里我一共发生了4次交互和重排序,所以会回调4次,打印如下:
2021-09-16 09:11:50.546 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:11:52.396 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 6 toPos = 7
2021-09-16 09:11:53.520 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 7 toPos = 8
2021-09-16 09:11:54.995 7881-7881/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 8 toPos = 5
从这个打印也可以看出,每次交换都会触发onMove。
当然上面代码是逻辑正确的,且写好数据交换的,这里想写好逻辑必须要了解这个回调函数的参数和返回值。
参数:
recyclerView:表示正在被拖拽的recyclerView。
viewHolder:正在被拖拽的ViewHolder。
target:目标被替换的ViewHolder。
返回值:
函数返回true,ItemTouchHelper会认为这个viewHolder从原位置已经move到target位置,所以你要返回true,就必须是真的做了处理。
比如下面代码,我任何操作都不做,直接返回true:
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPosition = viewHolder.adapterPosition
val toPosition = target.adapterPosition
Log.i(TAG, "onMove: fromPos = $fromPosition toPos = $toPosition")
//如果[viewHolder]已经移动到[target]位置, 则返回[true]
return true
}
效果如下:
打印如下:
2021-09-16 09:35:10.174 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.183 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.191 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.199 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.208 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.216 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.225 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.232 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.241 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.249 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.258 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.266 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.274 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.283 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.291 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.299 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.308 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.316 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 6
2021-09-16 09:35:10.332 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.341 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.349 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.358 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.366 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.374 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.382 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.391 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.399 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.408 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.416 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.424 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.432 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.441 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.483 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.491 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
2021-09-16 09:35:10.499 6968-6968/com.angcyo.dsladapter.demo I/zyh: onMove: fromPos = 5 toPos = 8
会发现虽然动画效果有了,确无法正确替换位置,会不停的回调,所以这里正确的逻辑是当onMove回调时,把这2个位置的数据替换,且调用notify来刷新2个位置的数据:
下面是正常的处理:
//发生拖拽时回调
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPosition = viewHolder.adapterPosition
val toPosition = target.adapterPosition
Log.i(TAG, "onMove: fromPos = $fromPosition toPos = $toPosition")
//如果[viewHolder]已经移动到[target]位置, 则返回[true]
return _dslAdapter?.run {
//拿数据这里代码可以忽略,是源库有这个逻辑
val validFilterDataList = getValidFilterDataList()
val fromItem = validFilterDataList.getOrNull(fromPosition)
val toItem = validFilterDataList.getOrNull(toPosition)
if (fromItem == null || toItem == null) {
//异常操作 返回false
false
} else {
//这里代码可以忽略,源库有这个逻辑,这里做的操作就是互换2个位置的值
val fromPair = getItemListPairByItem(fromItem)
val toPair = getItemListPairByItem(toItem)
val fromList: MutableList<DslAdapterItem>? = fromPair.first
val toList: MutableList<DslAdapterItem>? = toPair.first
if (fromList.isNullOrEmpty() && toList.isNullOrEmpty()) {
false
} else {
Collections.swap(validFilterDataList, fromPosition, toPosition) //界面上的集合
if (fromList == toList) {
Collections.swap(fromList, fromPair.second, toPair.second) //数据池的集合
} else {
val temp = fromList!![fromPair.second]
fromList[fromPair.second] = toList!![toPair.second]
toList[toPair.second] = temp
}
_updateAdapterItems()
//互换完数据,再调用notifyItemMove即可
notifyItemMoved(fromPosition, toPosition)
_dragHappened = true
onItemMoveChanged?.invoke(fromList!!, toList!!, fromPair.second, toPair.second)
true
}
}
} ?: false
}
总结一下:当onMove调用时就是发生了拖拽,想正确处理需要先交换2个位置的值,再调用notifyItemMove来刷新界面,最后都操作完返回true,否则返回false。
关于拖拽还有一个回调,就是是否可以把当前viewHolder和target的viewHolder进行交换,这个有个回调来控制,叫做canDropOver。
canDropOver
方法原型:
public boolean canDropOver(@NonNull RecyclerView recyclerView, @NonNull ViewHolder current,
@NonNull ViewHolder target) {
return true;
}
注释:
Return true if the current ViewHolder can be dropped over the the target ViewHolder.
非常简单,返回true就表示当前的ViewHolder可以被目标ViewHolder替换。
比如下面代码:
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
//当被拖拽的viewHolder是5时,则无法被拖拽替换
return current.absoluteAdapterPosition != 5
return super.canDropOver(recyclerView, current, target)
}
效果如下,拖拽pos等于5个view,无法和别的进行交换:
总结
对于拖拽和侧滑删除是在平时开发中经常可能会用到的,同时了解了ItemTouchHelper这个类的各个回调意义对我们做其他的需求也非常有用,下面来做个总结,方便大家按照自己需求实现。
其中源码部分大家可以查看上面说的开源库中的DragCallbackHelper类,就不复制大量代码了,主要说一下流程:
侧滑删除
对于侧滑删除主要就是上面3个方法回调,具体逻辑根据需求制定。
拖拽替换
对于拖拽也就是上面几个方法回调,注意先后顺序以及onMove中的操作即可。
本章内容实现的效果其实不局限于此,这里主要是要了解ItemTouchHelper几个回调的用法,其他很多效果都是在这几个回调里实现,后续有补充的话再更新。