ItemTouchHelper实现RecyclerView拖拽&合并的效果
效果演示
左侧栏是一个RecyclerView,通过手势拖拽可以进行排序,合并成文件夹(类似桌面应用图标合并的交互)
- 图标支持拖拽调整顺序。
- 图标重叠时将两个图标合并成文件夹。
- 点击文件夹可以切换文件夹折叠/展开状态。
- 文件夹展开状态下支持将图标拖入文件夹或者从文件夹中拖出。
ItemTouchHelper.Callback可以快速实现拖拽排序,滑动移出的效果,但是想要实现合并的交互效果还需要加以改造。
看完上面的效果图和交互点,首先想到的就是ItemTouchHelper.Callback接口(想偷懒T.T)。
实现思路
先说结论:
1、我们是通过重写chooseDropTarget()方法,当两个viewHolder重叠的部分满足触发合并的条件时,用一组变量将这两个viewHolder暂存起来,否则清空这组变量。
2、最后在拖拽结束时(也就是手抬起)如果这两个viewHolder不为空,则触发合并逻辑。
先看源码
因为要对ItemTouchHelper#Callback进行一些改造,先得对ItemTouchHelper的实现原理有所了解,然后再进行修改。
这里不打算对ItemTouchHelper源码做过多详细的解析,只是挑出我感觉比较有用的节点聊聊。
1、ViewHolder被拖动
我们拖动viewHolder时,触摸事件是由mOnItemTouchListener#onTouchEvent()进行分发的,在处理MotionEvent.ACTION_MOVE事件时,再通过调用moveIfNecessary(ViewHolder viewHolder)来实现viewHolder的位置交换。
/**
* Checks if we should swap w/ another view holder.
*/
@SuppressWarnings("WeakerAccess") /* synthetic access */
void moveIfNecessary(ViewHolder viewHolder) {
if (mRecyclerView.isLayoutRequested()) {
return;
}
if (mActionState != ACTION_STATE_DRAG) {
return;
}
final float threshold = mCallback.getMoveThreshold(viewHolder);
final int x = (int) (mSelectedStartX + mDx);
final int y = (int) (mSelectedStartY + mDy);
if (Math.abs(y - viewHolder.itemView.getTop()) < viewHolder.itemView.getHeight() * threshold
&& Math.abs(x - viewHolder.itemView.getLeft())
< viewHolder.itemView.getWidth() * threshold) {
return;
}
// 1、找到可以交换位置的目标viewHolder
List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
if (swapTargets.size() == 0) {
return;
}
// may swap.
// 2、选择需要被替换位置的viewHolder
ViewHolder target = mCallback.chooseDropTarget(viewHolder, swapTargets, x, y);
if (target == null) {
mSwapTargets.clear();
mDistances.clear();
return;
}
final int toPosition = target.getAdapterPosition();
final int fromPosition = viewHolder.getAdapterPosition();
// 3、触发这两个viewHolder的位置交换
if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
// keep target visible
mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
target, toPosition, x, y);
}
}
小结一下:
-
ItemTouchHelper在绑定RecyclerView的时候会注册mOnItemTouchListener这监听器来分发触摸事件,让viewHolder跟随手指移动。
-
通过 mCallback#chooseDropTarget() 方法获取需要交换位置的viewHolder,当target == null时表示没有找到可以触发位置交换的viewHolder。(这个方法就是我们需要改造的点)。
-
最后在 mCallback#onMove() 方法中来执行viewHolder的位置交换。
2、交换ViewHolder位置
chooseDropTarget(ViewHolder selected, List dropTargets, int curX, int curY) 是用来返回一个ViewHolder与被拖动的视图交换位置。如果返回null则不会触发位置交换。
方法参数:
- selected = 被拖动的viewHolder
- dropTargets = 是一组viewHolder的list,需要我们从这个list中找出用于交换位置的viewHolder,并返回这个viewHolder
- curX = selected在X轴方向将要去到的位置
- curY = selected在Y轴方向将要去到的位置
Callback#chooseDropTarget()方法有默认的实现,这里只是挑出Y轴方向的部分看看(X轴方向原理类似)。
@SuppressWarnings("WeakerAccess")
public ViewHolder chooseDropTarget(@NonNull ViewHolder selected,
@NonNull List<ViewHolder> dropTargets, int curX, int curY) {
int right = curX + selected.itemView.getWidth();
int bottom = curY + selected.itemView.getHeight();
ViewHolder winner = null;
int winnerScore = -1;
final int dx = curX - selected.itemView.getLeft();
final int dy = curY - selected.itemView.getTop();
final int targetsSize = dropTargets.size();
for (int i = 0; i < targetsSize; i++) {
final ViewHolder target = dropTargets.get(i);
// ...
// 省略x轴方向的代码部分
// ...
// 1、拖动selected向上移动(dy < 0)
if (dy < 0) {
// 2、比较两个viewHolder的top,判断这次拖动的距离是否越过了target
int diff = target.itemView.getTop() - curY;
if (diff > 0 && target.itemView.getTop() < selected.itemView.getTop()) {
final int score = Math.abs(diff);
if (score > winnerScore) {
winnerScore = score;
winner = target;
}
}
}
// 3、拖动selected向下移动(dy > 0)
if (dy > 0) {
// 4、比较两个viewHolder的bottom,判断这次拖动的距离是否越过了target
int diff = target.itemView.getBottom() - bottom;
if (diff < 0 && target.itemView.getBottom() > selected.itemView.getBottom()) {
final int score = Math.abs(diff);
if (score > winnerScore) {
winnerScore = score;
winner = target;
}
}
}
}
return winner;
}
结合示意图可以看出,默认实现的逻辑就是,当被拖动的ViewHolder位置跨越了target时,就把这个ViewHolder赋值给winner,最后返回这个winner对象。
改造一下
1、重写chooseDropTarget()
要实现viewHolder合并的效果,只要对chooseDropTarget稍微做些调整,在重叠的时候暂存viewHolder,并且确保chooseDropTarget在重叠状态下返回 null 就可以了。
// 用来暂存满足合并条件的viewHolder
private val winnerSetX = mutableSetOf<RecyclerView.ViewHolder>()
private val winnerSetY = mutableSetOf<RecyclerView.ViewHolder>()
override fun chooseDropTarget(selected: RecyclerView.ViewHolder, dropTargets: MutableList<RecyclerView.ViewHolder>, curX: Int, curY: Int): RecyclerView.ViewHolder? {
val right = curX + selected.itemView.width
val bottom = curY + selected.itemView.height
var winner: RecyclerView.ViewHolder? = null
// winnerScore 一个阈值,当超过阈值触发item位置变化,处于 (-winnerScore, winnerScore)区间触发合并成文件夹
var winnerScoreY = (selected.itemView.height * 0.3).toInt()
var winnerScoreX = (selected.itemView.width * 0.3).toInt()
val dx = curX - selected.itemView.left
val dy = curY - selected.itemView.top
val targetsSize = dropTargets.size
for (i in 0 until targetsSize) {
val target = dropTargets[i]
// 1、拖动selected向上移动(dy < 0)
if (dy < 0) {
val diff = target.itemView.top - curY
if (diff in -winnerScoreY..winnerScoreY) {
Log.d(TAG, "chooseDropTarget: y 满足条件, target = ${target.adapterPosition}")
// 2、暂存Y轴方向满足合并条件的viewHolder
winnerSetY.add(target)
} else {
winnerSetY.remove(target)
if (diff > 0 && target.itemView.top < selected.itemView.top) {
val score = abs(diff)
if (score > winnerScoreY) {
winnerScoreY = score
winner = target
}
}
}
}
// 3、拖动selected向下移动(dy > 0)
if (dy > 0) {
val diff = target.itemView.bottom - bottom
if (diff in -winnerScoreY..winnerScoreY) {
Log.d(TAG, "chooseDropTarget: y 满足条件, target = ${target.adapterPosition}")
// 4、暂存Y轴方向满足合并条件的viewHolder
winnerSetY.add(target)
} else {
winnerSetY.remove(target)
if (diff < 0 && target.itemView.bottom > selected.itemView.bottom) {
val score = abs(diff)
if (score > winnerScoreY) {
winnerScoreY = score
winner = target
}
}
}
}
// ...
// 省略x轴方向的逻辑
// ...
}
findMergeTarget(selected)
return winner
}
/**
* 找到满足合并条件的ViewHolder
*/
private fun findMergeTarget(selected: RecyclerView.ViewHolder) {
val target = when {
// 同时满足x、y轴法相的ViewHolder
horizontal && vertical -> winnerSetX.find { winnerSetY.contains(it) }
// 满足y轴法相的ViewHolder
vertical -> winnerSetY.firstOrNull()
// 满足x轴法相的ViewHolder
horizontal -> winnerSetX.firstOrNull()
// default
else -> null
}
Log.d(TAG, "findMergeTarget: position = ${target?.adapterPosition}")
if (target != null) {
onStashMergeHolder(selected, target)
} else {
onClearMergeHolder()
}
winnerSetX.clear()
winnerSetY.clear()
}
其实逻辑很简单,diff in -winnerScoreY..winnerScoreY 判断viewHolder是否处于重叠状态,然后保存viewHolder的引用。
2、重写onSelectedChanged()
最后在拖动结束的时候,判断是否需要触发合并操作即可。这里我们直接重写下 Callback.onSelectedChanged()方法就可以了。
/**
* 拖拽动作结束时,判断是否需要触发合并操作
*/
@SuppressLint("NotifyDataSetChanged")
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
Log.d(TAG, "onSelectedChanged: viewHolder = ${viewHolder?.adapterPosition ?: -1} | actionState = $actionState")
// 开始拖拽
if (viewHolder != null && actionState == ACTION_STATE_DRAG) {
mDragHandler?.onStartDrag(viewHolder)
}
// 结束拖拽
if (viewHolder == null && actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
// 合并操作和拖拽操作设计成互斥
val performMerge = mergeTarget != null && mergeSelected != null
if (performMerge) {
performMergeAction()
}
onClearMergeHolder()
mDragHandler?.onStopDrag(performMerge)
}
}
/**
* 执行合并操作,并触发回调
*/
@SuppressLint("NotifyDataSetChanged")
private fun performMergeAction() {
if (mergeTarget != null && mergeSelected != null) {
val fromPosition = mergeSelected?.adapterPosition ?: -1
val toPosition = mergeTarget?.adapterPosition ?: -1
if (fromPosition < 0 || toPosition < 0) {
return
}
mDragHandler?.onMergeData(fromPosition, toPosition)
Log.d(TAG, "onSelectedChanged: 合并 ${mergeTarget?.adapterPosition} and ${mergeSelected?.adapterPosition}")
}
}
接口封装
我们做了一些封装,并且提供了3个接口IDragAdapter、IDragItem、IDragHandler这样可以根据不同场景实现特定的功能。
1、IDragItem
描述:列表拖拽项接口,由RecyclerView的具体ViewHolder实现,用于判断是否可以拖动、合并和显示拖拽状态等。
| 方法名 | 描述 |
|---|---|
| canDrag(): Boolean | 是否可以拖动 |
| canMerge(): Boolean | 是否可以合并 |
| acceptMerge(): Boolean | 是否接收合并 |
| showMergePreview(holder: RecyclerView.ViewHolder?, show: Boolean) | 显示合并预览效果 |
| showDragState(holder: RecyclerView.ViewHolder?, isCurrentlyActive: Boolean) | 显示拖动状态 |
2、IDragAdapter
描述:适配器接口,由RecyclerView的具体Adapter实现。
| 方法名 | 描述 |
|---|---|
| getDragData(): List | 获取适配器列表数据 |
| getDragItem(viewHolder: RecyclerView.ViewHolder?): IDragItem? | 根据ViewHolder获取对应的DragItem对象 |
3、IDragHandler
描述:拖拽处理接口,拖拽条件判断、回调监听、合并处理逻辑。接入拖拽功能时需要实现这个接口,并且将这个处理器通过DragTouchCallback#setDragHandler()赋值。
| 方法名 | 描述 |
|---|---|
| swapPosition(fromPosition: Int, toPosition: Int): Boolean | 是否可以交换位置 |
| onBeforeSwap(fromPosition: Int, toPosition: Int) | 交换位置前回调 |
| onAfterSwap(fromPosition: Int, toPosition: Int) | 交换位置后回调 |
| onMergeData(fromPosition: Int, toPosition: Int) | 合并逻辑 |
| onStartDrag(viewHolder: RecyclerView.ViewHolder?) | 开始拖拽 |
| onStopDrag(performMerge: Boolean) | 结束拖拽 |
如何使用
1、实现IDragHandler#onMergeData()
override fun onMergeData(fromPosition: Int, toPosition: Int) {
val list: MutableList<IDragData> = adapter.mList
val fromData = list.get(fromPosition)
val toData = list.get(toPosition)
val mergeData = // TODO 结合具体需要实现
// 添加到list
list.add(toPosition, mergeData)
// 移除这两项数据
list.remove(toData)
list.remove(fromData)
// 更新适配器
notifyDataSetChanged()
}
2、DragTouchCallback绑定RecyclerView
我们提供了 DragTouchCallback 继承了 ItemTouchHelper.Callback(),并且围绕上面的3个接口进行了调用。实现完上述的3个接口后,将IDragHandler对象注册到DragTouchCallback上,并且绑定recyclerView就可以实现拖拽、合并的效果了。
fun initView() {
val itemTouchCallback = DragTouchCallback(mAdapter, horizontal = true, vertical = true)
// step 1:把你已经实现的处理器,注册给DragTouchCallback
itemTouchCallback.setDragHandler(YourHandlerImpl(recyclerView, mAdapter))
// step 2:绑定RecyclerView
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(recyclerView)
}
最后
感谢大家看到最后,git项目中有些Demo演示。
github地址:RecyclerViewDrag
Repect~
演示效果:合并文件夹
演示效果:合并相加