查看原文
其他

RecyclerView 滑动删除实现思路

giswangsj AndroidPub 2023-02-21

作者:giswangsj
https://juejin.cn/post/6997013239926095880

侧滑菜单是 App 中常见的一个功能,理解了它的原理可以对自定义 ViewGroup 的测量、摆放及触摸事件的处理有更深的理解。本文主要讨论如何通过两种实现方式实现,以及两者的异同点,各自的缺陷等。

为什么有两种实现呢?这个效果可以从不同的角度来实现:

  • 一种是父布局来处理、分发事件,控制子 view 的位置,也就是通过自定义 RecyclerView 实现
  • 另一种是通过子 ViewGroup 拦截事件,处理事件来实现,也就是自定义 ItemView 的布局

两种方式分别对应于我们熟知的内部拦截法和外部拦截法,但从布局方式到事件拦截、事件处理等基本思路都是相同的。

首先是布局,content 占满屏幕,菜单View在屏幕之外,当滑动的时候,content 滑屏幕,menu 进入屏幕,就达到了需要的效果,布局草图如下:

接着分别看一下两种方式如何实现:

1. 自定义RecyclerView

自定义RecyclerView方式有三个关键点:

  • 根据触摸点找到触摸的ItemView
  • 何时拦截事件
  • 如何让的Menu展开/隐藏

1.1,根据触摸点找到触摸的ItemView

首先RecyclerView是通过复用ItemView来避免创建大量对象,提高性能的,因此它内部的子view也就是一屏中可以看到的那些ItemView,可以通过遍历RecyclerView的所有子View,根据子View的 Bound,也就是一个 Rect,来判断触摸点是不是在这个ItemView中,也就能找到触摸点所在的ItemView。代码如下:

Rect frame = new Rect();

final int count = getChildCount();
for (int i = count - 1; i >= 0; i--) {
 final View child = getChildAt(i);
 if (child.getVisibility() == View.VISIBLE) {
            // 获取子view的bound
            child.getHitRect(frame);
            // 判断触摸点是否在子view中
            if (frame.contains(x, y)) {
                return i;
            }
 }
}

1.2,何时拦截事件

RecyclerView需要处理手势事件,内部的ItemVIew也需要处理事件,那在何时去拦截事件呢?分以下两种情况:

  • ACTION_DOWN 时,如果已经有ItemView处于展开状态,并且这次点击的对象不是已打开的那个ItemView,则拦截事件,并将已展开的ItemView关闭。

  • ACTION_MOVE 时,有俩判断,满足其一则认为是侧滑:1. x方向速度大于y方向速度,且大于最小速度限制;2. x方向的侧滑距离大于y方向滑动距离,且x方向达到最小滑动距离;

代码如下:

public class SwipeDeleteRecyclerView extends RecyclerView {
    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        ...
        switch (e.getAction()) {
            // 第一种情况
            case MotionEvent.ACTION_DOWN:
                ...
                // 已经有ItemView处于展开状态,并且这次点击的对象不是已打开的那个ItemView
                if (view != null && mFlingView != view && view.getScrollX() != 0) {
                    // 将已展开的ItemView关闭
                    view.scrollTo(00);
                    // 则拦截事件
                    return true;
                }
              break;
             // 第二种情况
             case MotionEvent.ACTION_MOVE:
                mVelocityTracker.computeCurrentVelocity(1000);
                // 此处有俩判断,满足其一则认为是侧滑:
                // 1.如果x方向速度大于y方向速度,且大于最小速度限制;
                // 2.如果x方向的侧滑距离大于y方向滑动距离,且x方向达到最小滑动距离;
                float xVelocity = mVelocityTracker.getXVelocity();
                float yVelocity = mVelocityTracker.getYVelocity();
                if (Math.abs(xVelocity) > SNAP_VELOCITY && Math.abs(xVelocity) > Math.abs(yVelocity)
                        || Math.abs(x - mFirstX) >= mTouchSlop
                        && Math.abs(x - mFirstX) > Math.abs(y - mFirstY)) {

                    mIsSlide = true;
                    return true;
                }
                break;
                ...
        }
        ...
    }
}

拦截了事件以后就该处理事件了,接着往下看。

1.3,如何让的Menu展开/隐藏

接着在 onTouchEvent 中处理事件,控制Menu的隐藏与展开。

首先是在 ACTION_MOVE 中,如果处于侧滑状态则让目标 ItemView 通过 scrollBy() 跟着手势移动,注意判断边界

ACTION_UP 中,此时会产生两个结果:一个是继续展开菜单,另一个是关闭菜单。这两个结果又都分了两种情况:

  1. 当松手时向左的滑动速度超过了阈值,就让目标ItemView保持松手时的速度继续展开。
  2. 当松手时向右的滑动速度超过了阈值,就让目标ItemView关闭。
  3. 当松手时移动的距离超过了隐藏的宽度的一半(也就是最大可以移动的距离的一半),则让ItemVIew继续展开。
  4. 当松手时移动的距离小于隐藏的宽度的一半,则让ItemVIew关闭。
public boolean onTouchEvent(MotionEvent e) {
 obtainVelocity(e);
 switch (e.getAction()) {
  case MotionEvent.ACTION_MOVE:
   float dx = mLastX - x;
   // 判断边界
   if (mFlingView.getScrollX() + dx <= mMenuViewWidth
     && mFlingView.getScrollX() + dx > 0) {
    // 随手指滑动
    mFlingView.scrollBy((int) dx, 0);
   }
   break;
  case MotionEvent.ACTION_UP:
   int scrollX = mFlingView.getScrollX();
   mVelocityTracker.computeCurrentVelocity(1000);
   
   if (mVelocityTracker.getXVelocity() < -SNAP_VELOCITY) {    // 向左侧滑达到侧滑最低速度,则打开
    // 计算剩余要移动的距离
    int delt = Math.abs(mMenuViewWidth - scrollX);
    // 根据松手时的速度计算要移动的时间
    int t = (int) (delt / mVelocityTracker.getXVelocity() * 1000);
    // 移动
    mScroller.startScroll(scrollX, 0, mMenuViewWidth - scrollX, 0, Math.abs(t));
   } else if (mVelocityTracker.getXVelocity() >= SNAP_VELOCITY) {  // 向右侧滑达到侧滑最低速度,则关闭
    mScroller.startScroll(scrollX, 0, -scrollX, 0, Math.abs(scrollX));
   } else if (scrollX >= mMenuViewWidth / 2) { // 如果超过删除按钮一半,则打开
    mScroller.startScroll(scrollX, 0, mMenuViewWidth - scrollX, 0, Math.abs(mMenuViewWidth - scrollX));
   } else {    // 其他情况则关闭
    mScroller.startScroll(scrollX, 0, -scrollX, 0, Math.abs(scrollX));
   }
   invalidate();
   releaseVelocity();  // 释放追踪
   break;
 }
 return true;
}

这里通过 VelocityTracker 来获取滑动速度,通过 Scroller 来控制 ItemView 滑动。

1.4 缺陷

在 RecyclerView 的 Holder 的 onBindViewHolder() 中给滑出来的菜单添加点击事件即可响应删除:

override fun onBindViewHolder(holder: ViewHolder, position: Int) {

 holder.tvDelete.setOnClickListener {
  onDelete(holder.adapterPosition)
 }
}

但是由于 RecyclerView 的复用机制,需要在点了删除菜单删除 Item 后,让 Item 关闭,不然就会出现删除一个 Item 后往下滚动,会再出来一个已展开的 Item。

fun onDelete(it:Int){
 mData.removeAt(it)
 adapter.notifyItemRemoved(it)
        // 调用closeMenu()关闭该item
 mBinding.rvAll.closeMenu()
}

关闭的方法很简单,只需要让该 Item scrollTo(0, 0)即可

public void closeMenu() {
    if (mFlingView != null && mFlingView.getScrollX() != 0) {
        // 关闭
        mFlingView.scrollTo(00);
    }
}

因此该方式存在的缺陷是需要手动关闭已删除的 itemView。

最后看一下效果:

2. 自定义 ItemView

自定义 ItemView 方式和自定义 RecyclerView 方式总体思路是一致的,不同点有:

  • 自定义 ItemView 继承自 ViewGroup
  • 自定义 ItemView 需要对子 view 进行测量摆放(如果继承自 LinearLayout 可以简化这一步)
  • 自定义 ItemView 不仅需要拦截向下拦截事件(拦截子 View 的事件),还需要向上拦截,也就是拦截父 View 的事件

2.1 测量布局

测量过程比较简单,要将 contentView 和 menuView 分开测量。contentView 直接使用 measureChildWithMargins() 测量,测量的高度作为整个 item 的高度,menuView 的高度也要跟随其高度。menuView 测量时需要构造其对应的 widthMeasureSpecwidthMeasureSpec 进行测量。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
 super.onMeasure(widthMeasureSpec, heightMeasureSpec);
 // 隐藏的菜单的宽度
 mMenuViewWidth = 0;
        // content部分的高度
 mHeight = 0;
        // content部分的高度
 int contentWidth = 0;
    
 int childCount = getChildCount();
 for (int i = 0; i < childCount; i++) {
  View childView = getChildAt(i);
  if (i == 0) {
   // 测量ContentView
   measureChildWithMargins(childView, widthMeasureSpec, 0, heightMeasureSpec, 0);
   contentWidth = childView.getMeasuredWidth();
   mHeight = Math.max(mHeight, childView.getMeasuredHeight());
  } else {
   // 测量menu
   LayoutParams layoutParams = childView.getLayoutParams();
   int widthSpec = MeasureSpec.makeMeasureSpec(layoutParams.width, MeasureSpec.EXACTLY);
                        // mHeight作为其精确高度
   int heightSpec = MeasureSpec.makeMeasureSpec(mHeight, MeasureSpec.EXACTLY);
   childView.measure(widthSpec, heightSpec);
   mMenuViewWidth += childView.getMeasuredWidth();
  }
 }
 // 宽度取第一个Item(Content)的宽度
 setMeasuredDimension(getPaddingLeft() + getPaddingRight() + contentWidth,
   mHeight + getPaddingTop() + getPaddingBottom());
}

2.2 摆放布局

由于测量过程中已经确定了所有子 view 的宽高,因此直接摆放子 view 即可。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    int left = getPaddingLeft();
    for (int i = 0; i < childCount; i++) {
        View childView = getChildAt(i);
        childView.layout(left, getPaddingTop(), left + childView.getMeasuredWidth(), getPaddingTop() + childView.getMeasuredHeight());
        left = left + childView.getMeasuredWidth();
    }
}

2.3 拦截事件

自定义ItemView实现方式拦截事件有两方面:

  1. onInterceptTouchEvent() 中 return true 来实现拦截
  2. 通过 getParent().requestDisallowInterceptTouchEvent(true);阻止父 view 拦截事件

那么哪些情况需要拦截呢?其实和自定义 RecyclerView 方式差不多,分两种情况:

  • ACTION_DOWN 时,如果已经有 ItemView 处于展开状态,并且这次点击的对象不是已打开的那个ItemView,则拦截事件,并将已展开的 ItemView 关闭。

  • ACTION_MOVE 时,有俩判断,满足其一则认为是侧滑:1. x方向速度大于y方向速度,且大于最小速度限制;2. x方向的侧滑距离大于y方向滑动距离,且x方向达到最小滑动距离;

和自定义 RecyclerView 方式不同的是,自定义 RecyclerView 中可以持有已打开的 ItemView 的引用。而自定义 ItemView 中需要通过经常变量来保存已打开的 ItemView。代码就不放了,文末有。

2.4 消费事件

消费事件也就是在 onTouchEvent 中对事件进行处理,实现侧滑及展开隐藏效果。实现思路也和自定义 RecyclerView 方式基本一致,这里不多说了。

2.5. 删除 Item

删除也是通过给 menuView 添加点击事件实现,和自定义 RecyclerView 方式不同之处在于不需要手动调用关闭该 ItemView 的操作。只需要在自定义 ItemView 的 onDetachedFromWindow 关闭并销毁即可。代码如下:

@Override
protected void onDetachedFromWindow() {
    if (this == mViewCache) {
        mViewCache.smoothClose();
        mViewCache = null;
    }
    super.onDetachedFromWindow();
}

2.6 局限

该方式存在一个局限就是通过 holder.itemView 添加的点击事件无效,需要给其中的 contentView 添加点击事件。

// 给itemView设置点击事件无效
holder.itemView.setOnClickListener {
    onClick(item)
}
// 给content设置点击事件
holder.itemContent.setOnClickListener {
    onClick(item)
}


3. 总结


3.1 共同点

两种方式的总体思路都是一样的:

  1. 布局 布局中的 content 部分宽度占据整个 ItemView 的宽度,菜单部分隐藏在 content 部分的右侧。

  2. 事件拦截 发生在 onInterceptTouchEvent

  • ACTION_DOWN 时,判断是否有打开的菜单,如果有并且不是当前事件所在的 Item,则拦截事件,并关闭菜单。
  • ACTION_MOVE 时,如果x方向的速度大于速度阈值并且大于y方向速度则或x方向移动距离大于距离阈值并且大于y方向移动的距离则拦截事件。
  1. 事件响应

发生在 onTouchEvent

  • ACTION_MOVE 时,通过 scrollBy() 让当前 ItemView 随着手指移动,注意判断边界。
  • ACTION_UP 时,如果向左滑动的速度大于阈值,并菜单没有完全打开,则通过 scroller 让其打开。需要根据速度及剩余距离计算展开需要的时间。
  • 同上当向右滑动的速度大于阈值,并菜单没有完全关闭,则通过 scroller 让其关闭。
  • ACTION_UP 时,如果滑动速度小于阈值,并且滑动距离超过 menu 部分宽度的一半,则通过 scroller 让其打开;如果滑动距离小于 menu 部分宽度的一半则关闭。

3.2 不同点

  • 自定义 RecyclerView 需要根据触摸点的位置找到对应的 itemView,并将展开的 itemView 对象保存其中;
  • 自定义 ItemView 只需通过静态变量保存当前打开的 itemView 对象即可。
  • 自定义 RecyclerView 在触发删除时需要在业务层手动关闭当前的 itemView 菜单。自定义 ItemView 可以自动关闭。
  • 自定义 RecyclerView 可以通过 xml 实现布局。自定义 ItemView 需要自己测量摆放子 view(当然可以直接继承 LinearLayout 简化这一步)。

3.3 缺陷

两种方式都需要在xml中引入,存在侵入性,同时也都存在一定缺陷:

  • 自定义 RecyclerView 方式在触发删除时需要手动关闭 menu
  • 自定义 ViewGroup 方式对 Item 的点击事件不能通过 holder.itemView 实现,需要放在内部的 content 上实现

但是自定义 RecyclerView 方式能很好地配合 ItemTouchHelper 实现长按拖拽排序效果。对这种配合 ItemTouchHelper 实现侧滑删除+长按拖拽排序。效果如下:

3.4 注意点

在手指快速滑动时需要根据手指抬起时的速度,以及剩余要滑动的距离来计算出要scroll的时间,这样就保证了自由滑动的速度和送手时的速度一致。可以避免卡顿的情况。

全部代码:https://github.com/wdsqjq/FengYunWeather

参考:https://blog.csdn.net/dapangzao/article/details/80524774


END

推荐文章 


 

交个朋友,进群聊聊



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存