ItemTouchHelper实现RecyclerView拖拽&合并的效果

3,092 阅读6分钟

ItemTouchHelper实现RecyclerView拖拽&合并的效果

效果演示

app-drag-list.gif

左侧栏是一个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对象。

未命名文件(2).jpg

改造一下

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~

演示效果:合并文件夹

演示效果:合并相加