美文网首页自定义View
总结一下RecyclerView侧滑菜单的两种实现

总结一下RecyclerView侧滑菜单的两种实现

作者: Camellia666 | 来源:发表于2021-08-16 21:08 被阅读0次

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

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

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

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

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

    mock.png

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

    一,自定义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(0, 0);
                        // 则拦截事件
                        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(0, 0);
        }
    }
    

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

    最后看一下效果:

    linear.gif grid.gif

    二,自定义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)
    }
    

    三,总结

    1,共同点

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

    1. 布局

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

    2. 事件拦截

      发生在onInterceptTouchEvent

      • ACTION_DOWN时,判断是否有打开的菜单,如果有并且不是当前事件所在的Item,则拦截事件,并关闭菜单。

      • ACTION_MOVE时,如果x方向的速度大于速度阈值并且大于y方向速度则或x方向移动距离大于距离阈值并且大于y方向移动的距离则拦截事件。

    3. 事件响应

      发生在onTouchEvent

      • ACTION_MOVE时,通过scrollBy()让当前ItemView随着手指移动,注意判断边界。

      • ACTION_UP时,如果向左滑动的速度大于阈值,并没菜单没有完全打开,则通过scroller让其打开。需要根据速度及剩余距离计算展开需要的时间。

      • 同上当向右滑动的速度大于阈值,并没菜单没有完全关闭,则通过scroller让其关闭。

      • ACTION_UP时,如果滑动速度小于阈值,并且滑动距离超过menu部分宽度的一半,则通过scroller让其打开;如果滑动距离小于menu部分宽度的一半则关闭。

    2,不同点
    • 自定义RecyclerView需要根据触摸点的位置找到对应的itemView,并将展开的itemView对象保存其中;

    自定义ItemView只需通过静态变量保存当前打开的itemView对象即可。

    • 自定义RecyclerView在触发删除时需要在业务层手动关闭当前的itemView菜单。自定义ItemView可以自动关闭。
    • 自定义RecyclerView可以通过xml实现布局。自定义ItemView需要自己测量摆放子view(当然可以直接继承LinearLayout简化这一步)。
    3,缺陷

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

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

    但是自定义RecyclerView方式能很好的配合ItemTouchHelper实现长按拖拽排序效果。对这种配合ItemTouchHelper实现侧滑删除+长按拖拽排序感兴趣的可以参看这里:CityManagerActivity.kt,效果如下:

    (https://github.com/wdsqjq/FengYunWeather/blob/master/app/src/main/java/me/wsj/fengyun/ui/activity/CityManagerActivity.kt)

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

    最后代码在这里:自定义RecyclerView自定义ItemView 仅供参考!!!

    版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
    本文链接:https://www.jianshu.com/p/5b7c46c62e5c

    参考:

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

    相关文章

      网友评论

        本文标题:总结一下RecyclerView侧滑菜单的两种实现

        本文链接:https://www.haomeiwen.com/subject/rgyvbltx.html