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

侧滑菜单是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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352

推荐阅读更多精彩内容