【ItemTouchHelper】微信发布朋友圈的图片移动和删除效果实现以及ItemTouchHelper原理浅析

1,877 阅读10分钟
2b813be5a2904b33366913c6851c6520

概述

想要实现类似朋友圈发布的图片拖动的功能,涉及到了复杂的移动判断逻辑。幸运的是Google已经帮我们提供了一个ItemTouchHelper,可以帮助我们实现该复杂的功能。

本文将会以以下几步展开,其中会解析某些使用到的API的原理。

  • ItemTouchHelper是什么
  • 移动功能的实现
  • 选中后放大 松手后缩小
  • 删除功能的实现
  • 修改移动事件触发的阈值
  • 限制最后一个“+”不能移动

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类来配置支持哪种交互类型,然后获取用户的交互事件进行处理。

他怎么做到的呢

我可以在源码中看到ItemTouchHelper是继承于RecyclerView.ItemDecoration

然后看看它里面有一个这样的一个函数 ItemTouchHelper#setupCallbacks()

private void setupCallbacks() {
    ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
    mSlop = vc.getScaledTouchSlop();
  	//把自己作为一个ItemDecoration设置给RecyclerView
    mRecyclerView.addItemDecoration(this);
  	//处理onTouchEvent和拦截TouchEvent
    mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
    mRecyclerView.addOnChildAttachStateChangeListener(this);
    startGestureDetection();
}

然后看RecyclerView#onDraw(Canvas c)

@Override
public void onDraw(Canvas c) {
    super.onDraw(c);

    final int count = mItemDecorations.size();
  	//调用ItemDecoration的onDraw
    for (int i = 0; i < count; i++) {
        mItemDecorations.get(i).onDraw(c, this, mState);
    }
}

然后看 OnItemTouchListener怎么处理onTouchEventOnItemTouchListener#onTouchEvent()

````` 省略一些别的代码
switch (action) {
    case MotionEvent.ACTION_MOVE: {
        // Find the index of the active pointer and fetch its position
        if (activePointerIndex >= 0) {
          	//设置dxdy
            updateDxDy(event, mSelectedFlags, activePointerIndex);
          	//判断是否需要移动 里面回回调onMove
            moveIfNecessary(viewHolder);
          	//这个主要处理拖动到边缘后移动recyclerview的 不是本期重点
            mRecyclerView.removeCallbacks(mScrollRunnable);
            mScrollRunnable.run();
          	//重绘recyclerview 
            mRecyclerView.invalidate();
        }
        break;
    }
`````

recycerview里面item的移动流程

接收到move事件后 RecyclerView.invalidate() ->RecyclerView.onDraw()->ItemTouchHelper.onDraw()->ItemTouchHelper.onChildDraw() 最后在onChildDraw()将对应的Child进行移动。

对于view的选中逻辑这里就不展开讲了

moveIfNecessary(viewHolder);这里是判断是否要移动item的 这里面的逻辑下面再介绍

移动功能的实现

        ItemTouchHelper(
            object : ItemTouchHelper.Callback() {
                //判断是否可侧滑和拖拽
                override fun getMovementFlags(recyclerView: RecyclerView,
                                              viewHolder: RecyclerView.ViewHolder): Int {
                  //我们可以上下左右拖拽 所以把UP,DOWN,LEFT,RIGHT都或一下,因为我们不支持滑动 所以第二个参数返回传0
                  return  makeMovementFlags(ItemTouchHelper.UP or 
                                            ItemTouchHelper.DOWN or 
                                            ItemTouchHelper.LEFT or 
                                            ItemTouchHelper.RIGHT, 0)
                }
                //item被拖拽到可移动的位置时会回调
                override fun onMove(recyclerView: RecyclerView, viewHolder:
                                    RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
                    //得到item原来的position
                    val fromPosition = viewHolder.adapterPosition
                    //得到目标position
                    val toPosition = target.adapterPosition
                    if (fromPosition == toPosition) return false
                    val list = adapter.list
                  
		    //将list的fromPosition移动到toPosition 移动方法类似冒泡
                    var form = fromPosition
                    for (i in IntProgression.fromClosedRange(
                        fromPosition,
                        toPosition,
                        toPosition.compareTo(fromPosition)
                    )) {
                        val temp = list[form]
                        list[form] = list[i]
                        list[i] = temp
                        form = i
                    }

                    adapter.notifyItemMoved(fromPosition, toPosition)
		    //如果已经被移动到目的地了 返回true
                    return true
                  
                }
                //item被滑动到可以删除时会回调
                override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                }
            }
        ).attachToRecyclerView(recyclerView)

好了 这样我们就可以实现图片的移动了,但是还不完整

效果如下

5c6dbbc2fdd556e8bebe203838105136

选中后放大 松手后缩小

当item的选中状态发生变化时,会回调ItemTouchHelper.Callback#onSelectedChanged()

我们可以在接收到状态改变的回调后改变大小

//重写callback的onSelectedChanged                                                                  
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {        
    when (actionState) {                                                                        
        //选中后回调                                                                                 
        ACTION_STATE_DRAG -> {                                                                  
            //保存当前拖拽的view                                                                       
            draggingView = viewHolder                                                           
            draggingView?.itemView?.apply {                                                      
                scaleX = 1.1f                                                                   
                scaleY = 1.1f                                                                   
            }                                                                                   
        }                                                                                       
        //松手后回调                                                                                 
        ACTION_STATE_IDLE -> {                                                                  
            draggingView?.let {                                                                  
                draggingView!!.itemView.apply {                                                  
                    scaleX = 1f                                                                 
                    scaleY = 1f                                                                 
                }                                                                               
                draggingView = null                                                             
            }                                                                                   
        }                                                                                       
    }                                                                                           
}                                                                                               

效果如下

145d97181542c2776217c19b1df46727

接下来还差item的删除

删除功能的实现

首先我们要在用户选中后显示删除区域,用户松手后隐藏删除区域

首先我们先定义两个动画,动画效果可以自定义

val showAnimation by lazy {
    TranslateAnimation(
        0f, 0f, deleteView.height.toFloat(), 0f
    ).apply {
        fillAfter = true
        duration = 200
        setAnimationListener(object : Animation.AnimationListener {
            override fun onAnimationStart(animation: Animation?) {
                deleteView.visibility = View.VISIBLE
            }

            override fun onAnimationEnd(animation: Animation?) {
            }

            override fun onAnimationRepeat(animation: Animation?) {
            }
        })
    }

}
val hideAnimation by lazy {
    TranslateAnimation(
        0f, 0f, 0f, deleteView.height.toFloat()
    ).apply {
        fillAfter = true
        duration = 200
        setAnimationListener(object : Animation.AnimationListener {
            override fun onAnimationStart(animation: Animation?) {
            }

            override fun onAnimationEnd(animation: Animation?) {
                deleteView.visibility = View.INVISIBLE
            }

            override fun onAnimationRepeat(animation: Animation?) {
            }
        })
    }

}

然后在onSelectedChanged()里面开启动画就可以了

//重写callback的onSelectedChanged
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
    when (actionState) {
        //选中后回调
        ACTION_STATE_DRAG -> {
            //显示删除区域
            deleteView.startAnimation(showAnimation)
            //保存当前拖拽的view
            draggingView = viewHolder
            draggingView?.itemView?.apply {
                scaleX = 1.1f
                scaleY = 1.1f
            }
        }
        //松手后回调
        ACTION_STATE_IDLE -> {
            //隐藏删除区域
            deleteView.startAnimation(hideAnimation)
            draggingView?.let {
                draggingView!!.itemView.apply {
                    scaleX = 1f
                    scaleY = 1f
                }
                draggingView = null
            }
        }
    }
}

效果如下

010c968e39dbef5c5aa72684199e5203

接下来就是要判断拖拽的item是不是到了删除区域。根据之前所说的我们知道 当recyclerview的item被移动的时候 会调用ItemTouchHelper.onChildDraw() 里面也会调用CallbackonChildDraw()

我们可以重写该方法,然后根据移动的xy来判断item是否移动到删除区域,然后进行下一步的处理(记得要调用一下父类的onChildDraw 里面有移动的逻辑 ),代码如下

 //item被移动的时候回调
 override fun onChildDraw(
     c: Canvas,
     recyclerView: RecyclerView,
     viewHolder: RecyclerView.ViewHolder,
     dX: Float,
     dY: Float,
     actionState: Int,
     isCurrentlyActive: Boolean
 ) {
     super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)

     val deleteViewLocations = IntArray(2)
     deleteView.getLocationInWindow(deleteViewLocations)
     //删除区域的顶部
     val deleteViewTop = deleteViewLocations[1]


     val dragViewLocations = IntArray(2)
     viewHolder.itemView.getLocationInWindow(dragViewLocations)
     //拖拽区域的底部 itemView的高度要*1.1是因为我们view被放大了所以我们高度也要一起变大
     val dragViewBottom = dragViewLocations[1] + viewHolder.itemView.height * 1.1f

     //判断拖拽区域的底部是否越过删除区域的顶部 保存是否在删除区域 当用户松手的时候我们可以用这个变量判断是否在删除区域松手
     if (dragViewBottom > deleteViewTop) {
         isDelete = true
         deleteView.alpha = .5f
     } else {
         isDelete = false
         deleteView.alpha = 1f
     }

 }

然后我们判断onSelectedChanged()回调的状态是ACTION_STATE_IDLE的话,判断isDelete的状态 如果是true的话则代表是删除操作

改一下onSelectedChanged()的代码

//松手后回调
ACTION_STATE_IDLE -> {

    //隐藏删除区域
    deleteView.startAnimation(hideAnimation)

    draggingView?.let {
        //是否在删除区域松手
        if (isDelete) {
            //这里需要把拖拽的view gone掉 不然还会显示在UI上
            draggingView!!.itemView.visibility = View.GONE
            val list = adapter.list
            list.removeAt(draggingView!!.adapterPosition)
            adapter.notifyItemRemoved(draggingView!!.adapterPosition)
        } else {
            draggingView!!.itemView.apply {
                scaleX = 1f
                scaleY = 1f
            }
        }
        draggingView = null
    }

}

最后删除的效果如下

73a5e34e5f24cd9bbc12d393a6a5f271

修改移动事件触发的阈值

08868c7788069a18d7cb994252a9f1b8

移动和删除都完成了,但是会发现拖拽的item需要越过替换目标的时候才会触发onMove的回调,有没有办法让这个回调提前一点呢,比如移动到75%的时候就触发。

顺着Callback.onMove往上找,看有没有提前调用onMove,最后发现是在ItemtouchHelper#moveIfNecessary()里面被调用的,源码如下

//上面介绍了 这个函数会在控件被拖动的时候onTouchEvent里面调用
void moveIfNecessary(ViewHolder viewHolder) {
    if (mRecyclerView.isLayoutRequested()) {
        return;
    }
    if (mActionState != ACTION_STATE_DRAG) {
        return;
    }
		//0 获取认为是移动事件的阈值 默认为0.5可以重写来修改
    final float threshold = mCallback.getMoveThreshold(viewHolder);
    final int x = (int) (mSelectedStartX + mDx);
    final int y = (int) (mSelectedStartY + mDy);
  	//如果view被拖动的距离没有超过阈值 则return
    if (Math.abs(y - viewHolder.itemView.getTop()) < viewHolder.itemView.getHeight() * threshold
            && Math.abs(x - viewHolder.itemView.getLeft())
            < viewHolder.itemView.getWidth() * threshold) {
        return;
    }
  	//1
    List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
    if (swapTargets.size() == 0) {
        return;
    }
    //2
    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();
  	//找到target后调用callback的onMove
    if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
        // keep target visible
        mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
                target, toPosition, x, y);
    }
}
  1. //该函数是寻找与拖动控件覆盖的view 会将return的结果以覆盖区域大小从大到小排序
    private List<ViewHolder> findSwapTargets(ViewHolder viewHolder) {
        if (mSwapTargets == null) {
            mSwapTargets = new ArrayList<>();
            mDistances = new ArrayList<>();
        } else {
            mSwapTargets.clear();
            mDistances.clear();
        }
        final int margin = mCallback.getBoundingBoxMargin();
        final int left = Math.round(mSelectedStartX + mDx) - margin;
        final int top = Math.round(mSelectedStartY + mDy) - margin;
        final int right = left + viewHolder.itemView.getWidth() + 2 * margin;
        final int bottom = top + viewHolder.itemView.getHeight() + 2 * margin;
        final int centerX = (left + right) / 2;
        final int centerY = (top + bottom) / 2;
        final RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager();
        final int childCount = lm.getChildCount();
      	//遍历RecyclerView里面的所有view
        for (int i = 0; i < childCount; i++) {
            View other = lm.getChildAt(i);
            if (other == viewHolder.itemView) {
                continue; //myself!
            }
          	//判断这个view是否被拖动view覆盖了
            if (other.getBottom() < top || other.getTop() > bottom
                    || other.getRight() < left || other.getLeft() > right) {
                continue;
            }
            final ViewHolder otherVh = mRecyclerView.getChildViewHolder(other);
          	//调用canDropOver来确定这个被覆盖的view是否能被替换(我们可以利用canDropOver这个回调来限制不能被移动的view)
            if (mCallback.canDropOver(mRecyclerView, mSelected, otherVh)) {
                // find the index to add
                final int dx = Math.abs(centerX - (other.getLeft() + other.getRight()) / 2);
                final int dy = Math.abs(centerY - (other.getTop() + other.getBottom()) / 2);
                final int dist = dx * dx + dy * dy;
    
                int pos = 0;
                final int cnt = mSwapTargets.size();
              	//寻找插入顺序,大的排前面
                for (int j = 0; j < cnt; j++) {
                    if (dist > mDistances.get(j)) {
                        pos++;
                    } else {
                        break;
                    }
                }
              	//添加到返回结果里面
                mSwapTargets.add(pos, otherVh);
                mDistances.add(pos, dist);
            }
        }
        return mSwapTargets;
    }
    
  2. //该函数是从被覆盖的view(dropTargets)里面中选取一个可以被拖动view替换的ViewHolder,然后将其返回
    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;
      	//curX是拖动view的当前x轴的位置 -去left 的到的就是x轴偏移量
        final int dx = curX - selected.itemView.getLeft();
      	//curY是拖动view的当前Y轴的位置 -去top 的到的就是y轴偏移量
        final int dy = curY - selected.itemView.getTop();
        final int targetsSize = dropTargets.size();
      	//遍历dropTargets 寻找最合适的替换目标 赋值给winner
        for (int i = 0; i < targetsSize; i++) {
            final ViewHolder target = dropTargets.get(i);
            if (dx > 0) {//向右移
                int diff = target.itemView.getRight() - right;
              	//diff小于0 代表着拖动的view右边界已经越过了覆盖view的右边界
                if (diff < 0 && target.itemView.getRight() > selected.itemView.getRight()) {
                  	//score的作用是为了寻找一个越界程度最大的target 那个gerget就是最终的winner
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
            if (dx < 0) {//向左移
                int diff = target.itemView.getLeft() - curX;
                //diff大于0 代表着拖动的view左边界已经越过了覆盖view的左边界
                if (diff > 0 && target.itemView.getLeft() < selected.itemView.getLeft()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
            if (dy < 0) {//向上移
                int diff = target.itemView.getTop() - curY;
                //diff大于0 代表着拖动的view上边界已经越过了覆盖view的上边界
                if (diff > 0 && target.itemView.getTop() < selected.itemView.getTop()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
    
            if (dy > 0) {//向下移
                int diff = target.itemView.getBottom() - bottom;
                //diff小于0 代表着拖动的view下边界已经越过了覆盖view的下边界
                if (diff < 0 && target.itemView.getBottom() > selected.itemView.getBottom()) {
                    final int score = Math.abs(diff);
                    if (score > winnerScore) {
                        winnerScore = score;
                        winner = target;
                    }
                }
            }
        }
        return winner;
    }
    

看了源码就很清晰了

控件拖动的时候会触发ItemtouchHelper#moveIfNecessary()

moveIfNecessary()里面

  1. 判断控件拖动的距离是否超过设定的阈值
  2. 0通过后,获取所有与拖动控件叠加的view
  3. 获取到的view不为空的话,选取一个最适合的view返回
  4. 如果2能选取到合适的view的话 则将替换的view作为参数调用onMove

根据源码我们发现 我们可以重写第2步,chooseDropTarget(因为之前是要整个越过才作为符合条件的view,不符合我们的预期)选取我们想要的targetview进行返回

重写代码如下,复制父类的代码 然后增加一个阈值 修改修改里面的diff

 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
     var winnerScore = -1f
     val dx = curX - selected.itemView.left
     val dy = curY - selected.itemView.top
		
   	//这里是0.75f 这个值不能小于getMoveThreshold()返回的阈值 如果想要小于的话,可以重写getMoveThreshold 返回一个最小值就好了
     val onMoveThreshold = 0.75f 
   
     dropTargets.forEach { target->
         
         val ignoreWidth = target.itemView.width * (1 - onMoveThreshold)
         val ignoreHeight = target.itemView.height * (1 - onMoveThreshold)
         
         if (dx > 0) {
             val diff = target.itemView.right - right - ignoreWidth
             if (diff < 0 && target.itemView.right > selected.itemView.right) {
                 val score = Math.abs(diff)
                 if (score > winnerScore) {
                     winnerScore = score
                     winner = target
                 }
             }
         }
         if (dx < 0) {
             val diff = target.itemView.left - curX + ignoreWidth
             if (diff > 0 && target.itemView.left < selected.itemView.left) {
                 val score = Math.abs(diff)
                 if (score > winnerScore) {
                     winnerScore = score
                     winner = target
                 }
             }
         }
         if (dy < 0) {
             val diff = target.itemView.top - curY + ignoreHeight
             if (diff > 0 && target.itemView.top < selected.itemView.top) {
                 val score = Math.abs(diff)
                 if (score > winnerScore) {
                     winnerScore = score
                     winner = target
                 }
             }
         }
         if (dy > 0) {
             val diff = target.itemView.bottom - bottom - ignoreHeight
             if (diff < 0 && target.itemView.bottom > selected.itemView.bottom) {
                 val score = Math.abs(diff)
                 if (score > winnerScore) {
                     winnerScore = score
                     winner = target
                 }
             }
         }
     } 
     return winner

 }

可以了 最后效果如下

530df457f54530aa93557f7af9017ad5

限制最后一个“+”不能移动

我们在修改移动阈值的时候 查看源码发现canDropOver()如果返回flase的话 则不会被当成可替换的目标

好了很开心 简简单单一行代码

override fun canDropOver(recyclerView: RecyclerView,
                         current: RecyclerView.ViewHolder, 
                         target: RecyclerView.ViewHolder): Boolean {
  	//这里判断逻辑简单起见 我就直接判断文案是否为“+”了 实际上可以判断viewholder的类型或其他方法
    return  adapter.list[target.adapterPosition] != "+"
}
7d76b98cbc3ad870da77ab05a08d031e

”+“的确不能被越过了,但是我们发现 虽然不能被越过 但是它还是能被拖拽的。。。。

怎么办呢,记不记得之前介绍的getMovementFlags这个函数,这个函数是来判断viewholder支持的交互类型的

那么很好 我们在里面判断 viewhoder是“+”的话 返回不可移动就可以了

最终代码如下

//判断是否可侧滑和拖拽
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
    return if (canMove(viewHolder)) {
        makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0)
    } else {
        makeMovementFlags(0, 0)
    }
}


override fun canDropOver(recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
    return canMove(target)
}

private fun canMove(holder: RecyclerView.ViewHolder): Boolean {
    return adapter.list[holder.adapterPosition] != "+"
}

运行一下 发现终于不能被拖动了。。

最终效果

2b813be5a2904b33366913c6851c6520

好了 虽然其中一些细节和微信的还会有一些差异 但是大体上差不多

如果想进一步了解拖动view是怎么被选中的以及一些列的事件传递 可以参考这篇文章

juejin.cn/post/684490…