万字长文解析侧滑菜单的俩种实现原理

前言

最近项目在优化一些组件和UI,其中有用到侧滑菜单,即可以对RecyclerView的条目进行侧滑,显示出侧滑菜单。其大致效果如下,这是开源库DslAdapter中的实现效果:

dsl侧滑样例.gif

本来我以为这个是一个比较简单的东西,就在网上找了一个实现的轮子,结果后面项目需求越来越多,这个轮子在反复修补之后,问题不断,需要认真研究一下其实现,来彻底搞清楚这个。

关于文章demo代码可以查看:

github.com/horizon1234…

参考的开源库DslAdapter源码:

github.com/angcyo/DslA…

正文

本章涉及的内容比较多,有基础知识点,也有复杂点的知识,这里不论知识点难易程度,凡是涉及的地方,都尽量讲清楚。

所以读完本篇文章,可以掌握如下知识点:

  • 手势识别器的相关使用。
  • RecyclerView的ItemDecoration的相关使用。
  • MotionEvent事件拦截以及实现细节。
  • 属性动画的相关使用。

大致思路

为了更加彻底搞明白侧滑菜单功能的实现,首先说一下该功能的实现大致思路。

拦截MotionEvent

我们知道在Android中,当手指点击屏幕或者滑动屏幕时,其实就是系统产生的一系列MotionEvent,即运动事件或者就叫做事件,而事件的传递相信大家都比较熟悉,先经过Activity,再经过一层层ViewGroup,最后到达最顶层的View来处理这个事件。

而这里的多个MotionEvent就组成了一系列的不同动作,即事件流,我们本章会涉及的3种:

  • 点击事件,即ACTION_DOWN + ACTION_UP。
  • 滑动事件,即ACTION_DOWN + N(>=1)个ACTION_MOVE。
  • Fling事件,中文翻译这个Fling的意思是"扔"、"撇"等动词,所以这个可以看成是快速滑动事件,或者干脆就不翻译叫做Fling事件。触发这个事件就是手指在屏幕上快速"滑过",在手指离开屏幕时,是有横向或者竖向的速度,这就是Fling事件。它的事件流必须由ACTION_DOWN开始,和ACTION_UP结束,中间可以有多个ACTION_MOVE。

既然了解了事件流,那就进入主题,我们首先要做的就是按需拦截这些事件流,为什么会有这种需求呢,我们先来看个例子:

image.png

比如这里Index2的侧滑菜单已经打开了,这时我点击Index3这个Item,根据一般需求(不同项目,需求有细微差别),这个Item是不能响应点击事件的,而是把Index2的侧滑菜单给收起来,即下面动图这样:

04.gif

所以我们要在MotionEvent到达RecyclerView之前进行一波拦截处理,这时第一反应就是利用事件传递流程,我们可以在RecyclerView的父布局进行拦截,或者在RecyclerView的dispatchTouchEvent方法中进行拦截处理,不过这些实现都有点复杂,其实RecyclerView早就想到了这一点,而且已经为我们实现好了API和接口。

RecyclerView的前置拦截器

我们只需要调用RecyclerView的addOnItemTouchListener方法即可,方法定义如下:

/**
 * Add an {@link OnItemTouchListener} to intercept touch events before they are dispatched
 * to child views or this view's standard scrolling behavior.
 *
 * <p>Client code may use listeners to implement item manipulation behavior. Once a listener
 * returns true from
 * {@link OnItemTouchListener#onInterceptTouchEvent(RecyclerView, MotionEvent)} its
 * {@link OnItemTouchListener#onTouchEvent(RecyclerView, MotionEvent)} method will be called
 * for each incoming MotionEvent until the end of the gesture.</p>
 *
 * @param listener Listener to add
 * @see SimpleOnItemTouchListener
 */
public void addOnItemTouchListener(@NonNull OnItemTouchListener listener) {
    mOnItemTouchListeners.add(listener);
}

我们从该方法的描述可知:通过添加一个OnItemTouchListener来拦截触摸事件,在这些事件被分派到子View或者本身的滑动行为之前。客户端代码可以使用这些监听来实现特定的操作,一旦OnItemTouchListener#onInterceptTouchEvent返回true表示拦截事件,则OnItemTouchListener#onTouchEvent方法会被调用来处理该事件。

有了该方法,我们在拦截MotionEvent就简单多了,但是我们可以做一个小小的深入,在上面该方法的描述中有这么一句话:

method will be called for each incoming MotionEvent until the end of the gesture

一旦onInterceptTouchEvent返回true时,onTouchEvent会被调用,来处理到来的MotionEvent,直到这个手势结束

这里看着是不是非常熟悉的逻辑,就是和View的事件分发定义的一样的接口,我们来看一下这个OnItemTouchListener接口的定义:

public interface OnItemTouchListener {
    
    boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);

    void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);

    void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
}

其中onRequestDisallowInterceptTouchEvent这个方法就是是否禁用事件拦截功能,即当其子View期望该Listener或者其父View不拦截该事件,可以禁用该事件拦截功能,这种情况下,事件拦截功能将无法生效;在View的事件分发中,我们用于解决滑动冲突使用内部拦截的实现,就是靠着这个类似的方法逻辑来实现的。

不得不说,官方库的源码设计的还是非常厉害的,到这里我们就解决了第一个大难题,即如何拦截这个事件,能拦截事件了,我们就好进行下一步定制化操作了,对于拦截啥事件的具体实现以及拦截哪些事件,后面正文解析时会细说。

侧滑菜单实现

既然可以在MotionEvent到达RecyclerView之前进行拦截处理,我便可以知道手指滑动的方向和距离,那这个侧滑菜单又如何实现的呢?

我们知道这个是在对RecyclerView的ViewHolder来进行处理的,即当我们滑动需要显示侧滑菜单的Item时,能够显示出侧滑菜单。所以现在我们提供2种思路,第一种是侧滑菜单就在ViewHolder中,第二种思路是利用RecyclerView的ItemDecoration类来实现,我们分别来看看。

带侧滑菜单的ViewHolder

通过事件拦截和处理,我们知道我们手指在ViewHolder上滑动的距离和方向,那我们便可以改变ViewHolder的translateX和translateY属性从而达到ViewHolder根据手指滑动的效果

而侧滑菜单我们可以设置为ViewHolder的一部分,比如下面这个ViewHolder是无法滑动的:

image.png

而它的XML布局如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >

    <LinearLayout
        android:id="@+id/bg"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        //省略

    </LinearLayout>

</RelativeLayout>

这里XML布局就是RecyclerView的Item布局,而下面这个ViewHolder是带侧滑菜单的:

image.png

image.png

这里为了效果明显,故意把侧滑按钮做的大一点,而这里的XML如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >

    <com.zyh.swipe.SwipeBtnContainer
        android:id="@+id/swipeContainer"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:layout_alignBottom="@id/bg"
        android:layout_alignTop="@id/bg"
        android:gravity="center_vertical"
        >

    </com.zyh.swipe.SwipeBtnContainer>

    <LinearLayout
        android:id="@+id/bg"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

      //省略
      
    </LinearLayout>

</RelativeLayout>

我们在跟布局添加了一个ViewGroup,从名字就可以看出这个是侧滑按钮容器,然后在这个容器内我们便可以添加侧滑按钮。

这里我们必须定死了规则,即侧滑按钮只能放在第一个子View的容器中,那我们在根据手指发生滑动事件时,把这个滑动的距离传递给ViewHolder,ViewHolder再根据特定的规则来设置其view的位置即可,比如下面方法就是在获取手指滑动后,来滑动ViewHolder:

open fun onSwipeTo(
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    x: Float,
    y: Float
) {
    SwipeUtils.onItemSwipeMenuTo(viewHolder, x, y)
}

这里的onSwipeTo的参数就分别是RecyclerView以及其需要滑动的ViewHolder,以及x和y,这时我们来看看如何实现来巧妙显示出侧滑按钮:

open fun onItemSwipeMenuTo(itemHolder: RecyclerView.ViewHolder, 
    dX: Float, dY: Float,
    //侧滑菜单类型
    itemSwipeMenuType: Int = SWIPE_MENU_TYPE_FLOWING) {
    val parent = itemHolder.itemView
    //ViewHolder的ItemView必须是ViewGroup,且子View个数大于1
    if (parent is ViewGroup && parent.childCount > 1) {
        //菜单最大的宽度, 用于限制滑动的边界
        val menuWidth = getSwipeMenuWidth(itemHolder)
        //tX是ViewHolder可以滑动的范围 注释1
        val tX = MathUtils.clamp(dX, -menuWidth.toFloat(), menuWidth.toFloat())
        parent.forEach { index, child ->
            if (index == 0) {
                //这个是侧滑菜单的容器 
                //这个菜单显示类型是FLOWING 注释2
                if (itemSwipeMenuType == SWIPE_MENU_TYPE_FLOWING) {
                    if (dX > 0) {
                        child.translationX = -menuWidth + tX
                    } else {
                        child.translationX = parent.mW() + tX
                    }
                } else {
                    //侧滑菜单显示类型是DEFAULT 注释3
                    if (dX > 0) {
                        child.translationX = 0f
                    } else {
                        child.translationX = (parent.mW() - menuWidth).toFloat()
                    }
                }
            } else {
                //其他非侧滑菜单的容器
                child.translationX = tX
            }
        }
    }
}

上面代码还是比较有意思的,它的思路就是修改ViewHolder中的各个View的translateX的属性,让其显示在不同的位置,具体我们来看一下,上面有3个注释:

  • 注释1,获取ViewHolder的可滑动范围,因为这个方法的dX和dY是期望ViewHolder滑动的位置,但是由于侧滑按钮有一定的宽度,我们的滑动范围是不能超过侧滑按钮的宽度的。这里获取菜单宽度代码:
fun getSwipeMenuWidth(itemHolder: RecyclerView.ViewHolder): Int{
    return itemHolder.itemView.getChildOrNull(0).mW()
}
fun View?.mW(def: Int = 0): Int {
    return this?.measuredWidth ?: def
}

可以发现默认就是获取itemView第一个子View的测量宽度,所以这也就是前面定死的规则,菜单容器必须是itemView的第一个子View

假设上图中的"删除"和"签收"按钮的宽度总和是100PX,那注释1的滑动范围tX就是-100到100,即使参数传递的dX不在这个范围,也要进行判断,而这里的判断区间是clamp函数:

public static float clamp(float value, float min, float max) {
    if (value < min) {
        return min;
    } else if (value > max) {
        return max;
    }
    return value;
}

这个函数我是第一次发现,它可以免去我们手动判断。

  • 注释2,在注释2代码中,其实就是对侧滑菜单容器ViewGoup的位置进行修改,这里我们发现有itemSwipeMenuType这个字段,在注释2这里的常量是FLOWING,即侧滑菜单容器是跟在正常Item后面出来的。

所以当dX > 0时,说明手势向右滑动,其中tX范围是0 .. 100(还是假设"删除"和"签收"2个按钮总和还是100PX),这时侧滑容器的的translationX的范围就是-100 .. 0,效果如下:

05.gif

当dx < 0时,说明手势向左滑动,其中tX范围是0 .. -100,假设屏幕宽度为300,那这时的侧滑容器的translationX的范围就是300 .. 200,效果如下:

06.gif

可以发现不论是左侧还是右侧的侧滑菜单,其侧滑菜单的效果都是跟在item上的。

  • 注释3,而注释3的地方给我实现了另外一种效果,即侧滑菜单在正常Item之下,我们依旧来来看一下效果。

当dX > 0时,说明手势向右滑动,其侧滑容器的布局会紧靠左边不变,即translationX始终为0,效果如下:

08.gif

当dX < 0时,说明手势向左滑动,其侧滑容器的布局会紧靠右边不变,即item宽度减去侧滑菜单宽度,效果如下:

09.gif

这里可以发现这种效果,是侧滑容器始终在item的内容下面。

总结来说,如果直接把侧滑菜单放到ViewHolder中,这个侧滑效果就是通过修改ViewHolder中侧滑菜单的translationX属性来达到侧滑的效果。这样做的好处就是点击事件容易处理,逻辑比较简单,而不足之处就是侧滑容器必须放在ViewHolder的第一个子View,而且对原本的RecyclerView的代码改动较多,侵入性有点多,这时我们就可以考虑另外一种方式来实现侧滑菜单。

ItemDecoration实现侧滑菜单

既然对每个ViewHolder的XML布局都要修改,这确实挺麻烦的,我们可以换个思路,就是直接在RecyclerView绘制Item的时候,给加上我们需要的侧滑菜单,这就要利用ItemDecoration这个知识点了,我们来一一分析。

ItemDecoration介绍和使用

为了搞懂每一个知识点,我们先来介绍一下这个ItemDecoration类。直接翻译就是Item装饰器,顾名思义就是对RecyclerView的Item进行修饰,我们直接来看一下该类定义:

/**
 * An ItemDecoration allows the application to add a special drawing and layout offset
 * to specific item views from the adapter's data set. This can be useful for drawing dividers
 * between items, highlights, visual grouping boundaries and more.
 *
 * <p>All ItemDecorations are drawn in the order they were added, before the item
 * views (in {@link ItemDecoration#onDraw(Canvas, RecyclerView, RecyclerView.State) onDraw()}
 * and after the items (in {@link ItemDecoration#onDrawOver(Canvas, RecyclerView,
 * RecyclerView.State)}.</p>
 */
public abstract static class ItemDecoration{

/**
 * Draw any appropriate decorations into the Canvas supplied to the RecyclerView.
 * Any content drawn by this method will be drawn before the item views are drawn,
 * and will thus appear underneath the views.
 */
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
    onDraw(c, parent);
}


/**
 * Draw any appropriate decorations into the Canvas supplied to the RecyclerView.
 * Any content drawn by this method will be drawn after the item views are drawn
 * and will thus appear over the views.
 */
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
        @NonNull State state) {
    onDrawOver(c, parent);
}


/**
 * Retrieve any offsets for the given item. Each field of <code>outRect</code> specifies
 * the number of pixels that the item view should be inset by, similar to padding or margin.
 * The default implementation sets the bounds of outRect to 0 and returns.
 */
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
        @NonNull RecyclerView parent, @NonNull State state) {
    getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
            parent);
}

}

从介绍中我们可知:ItemDecoration允许应用程序对itemView添加一个特殊的drawing或者一个布局位移,而这个功能对绘制分割线,高亮显示和分组显示等功能非常有用。同时可以对一个RecyclerView添加多个ItemDecoration,这些ItemDecoration的绘制顺序是按照他们添加的顺序,其中有3个重要的回调函数,下面来说一下。

  • onDraw:给RecyclerView绘制合适的装饰,但是所有内容的绘制都是在itemView绘制之前,所以该方法绘制的内容将出现在视图下方

  • onDrawOver:也是给RecyclerView绘制合适的装饰,但是所有内容的绘制都是在itemView绘制之后,所以该方法绘制的内容将出现在视图上方

  • getItemOffsets:给特定的Item添加偏移量,其实就是类似添加上了margin或者padding的效果。

关于ItemDecroation的使用这里推荐一篇文章:

cloud.tencent.com/developer/a…

介绍的比较详细,额外需要理解的点就是ItemDecoration绘制的时机,它的2个回调方法分别在RecyclerView的Item绘制前和绘制后,其实RecyclerView就是一个普通的ViewGroup,所以我们可以把侧滑菜单绘制在ItemDecoration的onDraw方法中。

这种思路和前面的自带侧滑菜单的ViewHolder就完全不同了,自带侧滑菜单的ViewHolder是菜单就是ItemView的一部分,而如果把侧滑菜单在ItemDecoration中绘制,则必须要考虑ViewGroup的绘制顺序,以及侧滑菜单的点击事件,这个我们后面细说。

正文解析

前面我们说了大致思路,其中主要包含2个方面,即拦截和处理MotionEvent,和侧滑菜单的实现,那从现在开始就来看看如何实现,由于2种侧滑菜单实现的细节不太一样,所以我们来一个个分析。

自带侧滑菜单的ViewHolder方式

本篇文章主要以学习思想为主,所以很多知识点可能会比较杂乱,我们先来看一些基础的知识,先看定义滑动方向。

滑动方向

我们可以想一下,RecyclerView中的Item是否有侧滑菜单,以及侧滑菜单的方向,这些都需要根据实际情需求来处理,所以我们定义一个SwipeMenuCallback类,然后在里面定义一些需要业务实现的需求,这里首先就是定义滑动方向。

其实不外乎就4种滑动方向,本来我是准备使用枚举类的,但是后来发现在RecyclerView库中的ItemTouchHelper中使用了Int类型来定义滑动方向,关于这个ItemTouchHelper类,有时间的话可以扩展说一下,也是非常重要的一个类。

滑动方向定义如下:

companion object {
    //上
    const val UP = 1
    //下
    const val DOWN = 1 shl 1
    //左
    const val LEFT = 1 shl 2
    //右
    const val RIGHT = 1 shl 3
    //无侧滑
    const val FLAG_NONE = 0
    //全方向
    const val FLAG_ALL = LEFT or
            RIGHT or
            DOWN or
            UP
    //竖直方向
    const val FLAG_VERTICAL = DOWN or UP
    //水平方向
    const val FLAG_HORIZONTAL = LEFT or RIGHT
}

这里使用1来表示上,同时进行向左移位从而得到其他几个方向,这样做有什么好处呢?一是节省内存,不用定义几个枚举或者String类型常量;二是方便计算,就比如FLAG_HORIZONTAL就是LEFT和RIGHT进行或运算即可,为此还可以利用其且运算来定义一个扩展函数have:

fun Int.have(value: Int): Boolean = this and value == value

使用的地方:

if(swipeFlag.have(RIGHT)){
    //...
}

感觉这种使用更加优雅一点,其实这种利用位运算的方式,在我们View的测量中就介绍过,不过平时写代码很少注意使用。

SwipeMenuCallback

简单说完这个定义方向后,我们来思考一下需要定义哪些函数来完成定制化的侧滑菜单功能,目前大致有如下方法:

open class SwipeMenuCallback {

    //每个ViewHolder的侧滑方向
    open fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        //默认可以向左侧滑
        return LEFT
    }

    //侧滑的阈值
    open fun getSwipeThreshold(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Float {
        //侧滑阈值,默认0.3F
        return 0.3F
    }

    //侧滑菜单的最大宽度
    open fun getSwipeMenuMaxWidth(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        return SwipeUtils.getSwipeMenuWidth(viewHolder)
    }

    //侧滑菜单的最大高度
    open fun getSwipeMenuMaxHeight(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        return SwipeUtils.getSwipeMenuHeight(viewHolder)
    }

    //速度阈值
    open fun getSwipeVelocityThreshold(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        defaultValue: Float
    ): Float {
        return defaultValue
    }

    //ViewHolder滑动
    open fun onSwipeTo(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        x: Float,
        y: Float
    ) {
        SwipeUtils.onItemSwipeMenuTo(viewHolder, x, y)
    }

}

这里我们可以发现不论是哪种实现方式,其中getMovementFlags都必须要根据业务实现的,而其他的方法在实际使用中可以按需重写,关于其中的阈值,暂时按下不表,等后面细节实现时详细说明。

GestureDetector手势识别器

前面说了在本章实现的侧滑中,我们需要判断滑动、Fling等情况,所以就需要使用手势识别器来简化我们判断事件流,但是对于手势识别器的使用,之前一直没有系统梳理过,所以就趁现在做个简单梳理。

我们可以把一系列的MotionEvent组成的事件看成是一个流,我们可以对这个流进行处理,当然处理只包括消费事件和识别计算,不会像Flow这种流一样可以把数据给修改了,所以可以想象现在有一个管道,里面装的就是一个个MotionEvent,现在它流到了GestureDetector,我们对它进行一些列处理。

我们重点复习一下,GestureDetector能识别出哪些手势,以及如何使用。下面代码是启动手势识别器的函数:

private fun startGestureDetection() {
    gestureDetectorCompat =
        GestureDetectorCompat(_recyclerView!!.context, itemTouchHelperGestureListener)
    gestureDetectorCompat?.setIsLongpressEnabled(false)
}

首先这里创建了一个GestureDetectorCompat的实例,构造参数第二个就是手势识别的回调,然后调用setIsLongpressEnabled(false),从字面翻译就是禁用长按,为什么要设置这个呢,我们看一下该方法:

/**
 * Set whether longpress is enabled, if this is enabled when a user
 * presses and holds down you get a longpress event and nothing further.
 * If it's disabled the user can press and hold down and then later
 * moved their finger and you will get scroll events. By default
 * longpress is enabled.
 *
 * @param enabled whether longpress should be enabled.
 */
public void setIsLongpressEnabled(boolean enabled) {
    mImpl.setIsLongpressEnabled(enabled);
}

从说明可知,设置是否禁用长按,如果长按是Enabled的,当用户按下屏幕而且保持一段时间,会产生一个长按事件,后续再滑动,是不会产生scroll事件的;如果长按是禁用的,当用户按下屏幕且保持,然后再滑动手指,将会产生一个scroll事件。

这个配置说明项还是非常关键的,不然到时候会发现长按后再滑动手指无法产生scroll事件就非常尴尬了,至于这里为什么会出现这种情况,我们后面可以查看一下手势识别器的源码来探究其设计;还有一点就是如果我想实现长按且拖拽,那该如何设置这个选项呢?

为了搞懂这个,我看了ItemTouchHelper类中的代码,里面有个RecyclerView的ItemView拖拽功能,我发现它里面的实现默认是支持长按的,但是它并没有在手势识别器中的scroll中做任何处理,而是利用前面说的在事件到达RecyclerView之前进行拦截,在其拦截处理的onTouchEvent方法中判断ACTION_MOVE事件,然后把ViewHolder进行拖拽的,所以这个事件拦截和处理就对开发者要求比较高,要能做到灵活运用

有点扯远了,说完这个禁用长按设置,我们来看一下手势识别器的构造方法以及其回调都有哪些手势可以识别:

//构造方法
public GestureDetectorCompat(Context context, OnGestureListener listener) {
    this(context, listener, null);
}
/**
 * 该监听用来当手势发生时通知客户端,如果想监听所有手势就实现该类,如果只是想
 * 监听部分手势可以继承SimpleOnGestureListener
 */
public interface OnGestureListener {

    /**
     * ACTION_DOWN事件触发
     */
    boolean onDown(MotionEvent e);

    /**
     * 单击事件
     */
    boolean onSingleTapUp(MotionEvent e);

    /**
     * 滑动事件
     */
    boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

    /**
     * 长按事件
     */
    void onLongPress(MotionEvent e);

    /**
     * Fling事件
     */
    boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
}

注意OnGestureListener的注释有说明,我们继承SimpleOnGestureListener接口,同时所有回调方法的返回值是boolean类型时,返回true都表示是消耗事件,返回false是不消耗事件

这个返回值尤其要注意,根据实际情况具体分辨,比如本章这里说的侧滑菜单的实现,就需要复杂的返回值机制,等会细说,还有就是一般情况下,我们都是直接使用SimpleOnGestureListener:

//快捷接口,建议手势识别器监听器都继承这个
public static class SimpleOnGestureListener implements OnGestureListener, OnDoubleTapListener,
        OnContextClickListener {

    public boolean onSingleTapUp(MotionEvent e) {
        return false;
    }

    public void onLongPress(MotionEvent e) {
    }

    public boolean onScroll(MotionEvent e1, MotionEvent e2,
            float distanceX, float distanceY) {
        return false;
    }

    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
            float velocityY) {
        return false;
    }

    public void onShowPress(MotionEvent e) {
    }

    public boolean onDown(MotionEvent e) {
        return false;
    }

    public boolean onDoubleTap(MotionEvent e) {
        return false;
    }

    public boolean onDoubleTapEvent(MotionEvent e) {
        return false;
    }

    public boolean onSingleTapConfirmed(MotionEvent e) {
        return false;
    }

    public boolean onContextClick(MotionEvent e) {
        return false;
    }
}

可以该类实现了3个接口,其中包括双击和鼠标点击事件的接口,而且凡是返回boolean类的方法都一律返回false,即手势识别仅仅是处理手势,并不会对事件进行消费。

拦截+处理

说完了很多前置知识,现在我们就来按照前面所说的大致思路中拦截MotionEvent来实现。

1656298521630.jpg

当一个事件流产生时,先进入onInterceptTouchEvent方法进行判断是否拦截,而在这个过程中,为了方便处理,我们可以把事件交由手势识别器来处理,也可以自己手动处理,该方法返回值表示是否拦截MotionEvent。

而手势识别器的返回值表示是否消费事件,当事件被消费时,即使onInterceptTouchEvent返回True,也不会进行进一步处理,因为这个事件被消费了。

所以这里2个类的返回值一定要搞清楚。

我们现在来具体分析一下本章的实现,该如何处理。由于是自带侧滑菜单的ViewHolder,再加上各种需求,我们初步的拦截策略有如下:

  • 对于ACTION_DOWN事件,由于它比较特殊,它是事件流的第一个MotionEvent,如果把他给拦截的话,后续事件都会交由RecyclerView的前置拦截器处理,所以对于ACTION_DOWN事件,必须返回false,不能进行拦截
  • 同样我们可以把ACTION_DOWN事件交由手势识别器,这时会触发onDown方法。而对于ACTION_DOWN的逻辑处理,因为不确定它是单击、滑动事件流,所以可做的逻辑操作也只有关闭已打开的侧滑菜单
  • 在onDown回调中,当我们是需要去关闭侧滑菜单,我们就不必在继续做处理,这时后续不论是ACTION_MOVE或者ACTION_UP事件,都不必继续再处理。

根据上面ACTION_DOWN规则,我们有如下代码:

//OnItemTouchListener#onInterceptTouchEvent方法
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
    return when (val actionMasked = e.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            Log.i(TAG, "onInterceptTouchEvent: ACTION_DOWN事件")
            _resetScrollValue()
            gestureDetectorCompat?.onTouchEvent(e)
        }
        else -> {
            Log.i(TAG, "onInterceptTouchEvent: $e")
            if (_needHandleTouch) {
                Log.i(TAG, "onInterceptTouchEvent: 需要继续处理")
                gestureDetectorCompat?.onTouchEvent(e)
            } else {
                Log.i(TAG, "onInterceptTouchEvent: 不需要继续处理")
                if (actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_CANCEL) {
                    Log.i(TAG, "onInterceptTouchEvent: 处理UP事件")
                    touchFinish()
                }
                false
            }
        }
    } ?: false
}

这里对于ACTION_DOWN事件,我们直接交由手势识别器来处理,而且返回值就和之前一样,默认返回false。

我们来看一下手势识别器是如何处理的:

//SimpleOnGestureListener#onDown方法
override fun onDown(e: MotionEvent?): Boolean {
    if (e != null) {
        val findSwipedView = findSwipedView(e)
        if (findSwipedView == null) {
            //注释1 没有点击到ViewHolder
            _needHandleTouch = false
            closeSwipeMenu(_swipeMenuViewHolder)
        } else {
            findSwipedView.apply {
                if (_lastValueAnimator?.isRunning == true ||
                    (_downViewHolder != null && _downViewHolder != this)
                ) {
                    //注释2 动画还没有完成
                    _needHandleTouch = false
                } else {
                    _downViewHolder = this
                    if (_swipeMenuViewHolder != null && _downViewHolder != _swipeMenuViewHolder) {
                        //注释3 关闭侧滑菜单
                        _needHandleTouch = false
                        closeSwipeMenu(_swipeMenuViewHolder)
                    }
                }
            }
        }
    } else {
        _needHandleTouch = false
    }
    return super.onDown(e)
}

这里代码内容较多,我们按注释分析:

  • 注释1,这里判断点击事件是否落到ViewHolder上,可以根据MotionEvent来获取ViewHolder:
private fun findSwipedView(motionEvent: MotionEvent): RecyclerView.ViewHolder? {
    val child: View = findChildView(motionEvent) ?: return null
    return _recyclerView?.getChildViewHolder(child)
}
fun findChildView(event: MotionEvent): View? {
    val x = event.x
    val y = event.y
    return _recyclerView?.findChildViewUnder(x, y)
}
public ViewHolder getChildViewHolder(@NonNull View child) {
    final ViewParent parent = child.getParent();
    if (parent != null && parent != this) {
        throw new IllegalArgumentException("View " + child + " is not a direct child of "
                + this);
    }
    return getChildViewHolderInt(child);
}

当点击事件没有落到ViewHolder上时,我们就没必要处理后续的滑动逻辑。

  • 注释2,当动画还没有完成时,ACTION_DOWN是不需要处理滑动逻辑的,这里的动画就是ViewHolder的移动动画,至于什么地方会用,一个是ViewHolder关闭时,为了不显突兀给加个属性动画;另一个就是Fling时打开ViewHolder的侧滑菜单,给加个属性动画。

  • 注释3,当已经侧滑的ViewHolder不是当前点击的ViewHolder,则在ACTION_DOWN触发时关闭已经打开的ViewHolder。

上面几种情况下,我们就对ACTION_DOWN事件进行了拦截处理,在onInterceptTouchEvent我们不进行拦截,始终返回false,然后在手势识别器的onDown回调中,我们区分了3种情况是需要关闭侧滑菜单的,而关闭侧滑菜单时,是不需要继续处理的,而其他情况则需要继续处理。

所以这里使用了一个标志位 _needHandleTouch 来表示是否需要继续处理。

而ACTION_DOWN出现时,继续处理的情况大致分为2种:

  • 当没有ViewHolder被侧滑打开时,这时可以点击Item或者滑动Item。
  • 当有ViewHolder被侧滑打开时,而且ACTION_DOWN事件正好发生在该ViewHolder上。

接下来逻辑就非常清晰了,还是来看事件流,假设接下来是一系列的ACTION_MOVE事件,即在手指触摸到屏幕后进行滑动,则还是先经过onInterceptTouchEvent方法:

image.png

我们依旧想象一下事件流就像是一个水管,这时来了一个ACTION_MOVE事件,如果_needHandleTouch为false,就表明在ACTION_DOWN中是关闭侧滑按钮,是不需要处理滑动的,所以这里直接返回false。而代码中为什么要额外判断一下当是ACTION_UP或者ACTION_CANCEL时的情况呢,这是为了做收尾工作,我们来看一下touchFinish函数:

fun touchFinish() {
    if (_needHandleTouch) {
        //省略
    }

    _downViewHolder = null
    _needHandleTouch = true
    _swipeFlags = 0
    _recyclerView?.parent?.requestDisallowInterceptTouchEvent(false)
}

我们从这里可以发现,当_needHandleTouch为false时,并不会执行省略的业务代码,而是进行收尾工作,把几个用到的标志位复位。我们进行了4项复位,其含义是:

  • _downviewHolder表示每次ACTION_DOWN触发的ViewHolder;
  • _needHandleTouch表示是否需要继续处理手势;
  • _swipeFlags表示正在侧滑或者正在处理的ViewHolder的侧滑方向Flag;
  • 不禁用事件拦截,即recyclerView的父View或者前置拦截器可以正常工作。

我们回到主线,来ACTION_MOVE事件到来时,且需要处理,为了方便,我们还是交给手势识别器,同时把手势识别器的返回值作为返回值,当多个ACTION_MOVE出现时,会触发手势识别器的onScroll方法,该方法如下:

//SimpleOnGestureListener#onScroll方法
override fun onScroll(
    e1: MotionEvent,
    e2: MotionEvent,
    distanceX: Float,
    distanceY: Float
): Boolean {
    val absDx: Float = abs(distanceX)
    val absDy: Float = abs(distanceY)
    if (absDx >= _slop || absDy >= _slop) {
        if (absDx > absDy) {
            _lastDistanceX = distanceX
        } else {
            _lastDistanceY = distanceY
        }
    }
    _lastVelocityX = 0f
    _lastVelocityY = 0f
    val downViewHolder = _downViewHolder
    val recyclerView = _recyclerView
    //注释1,上述代码记录上一次移动的距离
    if (recyclerView != null && downViewHolder != null) {
        val swipeFlag =
            swipeMenuCallback.getMovementFlags(recyclerView, downViewHolder)
        Log.i(TAG, "onScroll: 被按下ViewHolder的swipeFlag = $swipeFlag")
        if (swipeFlag <= 0) {
            Log.i(TAG, "onScroll: 不需要继续处理")
            _needHandleTouch = false
        } else {
            //本次滑动的意图方向
            val flag: Int = if (absDx > absDy) {
                ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
            } else {
                ItemTouchHelper.UP or ItemTouchHelper.DOWN
            }
            Log.i(TAG, "onScroll: 本次意图的方向 $flag")
            if (_swipeFlags == 0) {
                _swipeFlags = flag
            }
            //注释2,上述代码记录当前滑动手势的方向
            val swipeMaxWidth =
                swipeMenuCallback.getSwipeMenuMaxWidth(recyclerView, downViewHolder)
                    .toFloat()
            val swipeMaxHeight =
                swipeMenuCallback.getSwipeMenuMaxHeight(recyclerView, downViewHolder)
                    .toFloat()

            _scrollX -= distanceX
            _scrollX = MathUtils.clamp(_scrollX, -swipeMaxWidth, swipeMaxWidth)
            _scrollY -= distanceY
            _scrollY = MathUtils.clamp(_scrollY, -swipeMaxHeight, swipeMaxHeight)
            //注释3,对_scrollX和_scrollY进行范围限制
            Log.i(TAG, "onScroll: _scrollX = $_scrollX  _scrollY = $_scrollY")
            if (_swipeFlags == SwipeMenuCallback.FLAG_HORIZONTAL) {
                Log.i(TAG, "onScroll: 本次滑动是左右")
                if (swipeFlag.have(ItemTouchHelper.LEFT) ||
                    swipeFlag.have(ItemTouchHelper.RIGHT)
                ) {
                    //注释4,滑动方向和ViewHolder设置Flag方向一样
                    _scrollY = 0f
                    if (_scrollX < 0 && swipeFlag and ItemTouchHelper.LEFT == 0) {
                        Log.i(TAG, "onScroll: 手势左滑,但是不具备左侧滑")
                        _scrollX = 0f
                    } else if (_scrollX > 0 && swipeFlag and ItemTouchHelper.RIGHT == 0) {
                        Log.i(TAG, "onScroll: 手势右滑,但是不具备右滑")
                        _scrollX = 0f
                    } else {
                        _recyclerView?.parent?.requestDisallowInterceptTouchEvent(true)
                    }
                } else {
                    //注释5
                    Log.i(TAG, "onScroll: 手势左右滑动,但是该viewHolder不具备")
                    _swipeFlags = 0
                    _needHandleTouch = false
                    _scrollX = 0f
                    if (_swipeMenuViewHolder == _downViewHolder) {
                        //已经打开了按下的菜单, 但是菜单缺没有此方向的滑动flag
                        //则关闭菜单
                        Log.i(TAG, "onScroll: 左右滑动 flag是上下的")
                        closeSwipeMenu(_swipeMenuViewHolder)
                        return _needHandleTouch
                    } else {
                        _scrollY = 0f
                    }
                }
            } else {
                Log.i(TAG, "onScroll: 本次手势滑动是上下方向")
               //省略
            }

            swipeMenuCallback.onSwipeTo(
                recyclerView,
                downViewHolder,
                _scrollX,
                _scrollY
            )
        }
    } else {
        _needHandleTouch = false
    }

    return _needHandleTouch
}

这里代码很多,但是我们要明确一个点,因为这里有个隐藏的逻辑非常重要,就是拦截事件流的做法。这里我们不妨回顾一下Android系统事件分发过程,其中由3个函数来控制:

  • dispatchTouchEvent,用来进行事件的分发,如果事件能够传递给当前View,那么此方法一定会被调用。

  • onInterceptTouchEvent,该方法在上述方法内部调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一事件序列中,此方法不会被再次调用,返回结果表示是否拦截当前事件

  • onTouchEvent,在dispatchTouchEvent中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。

注意上面加黑的地方,这条规则也适用于RecyclerView的前置拦截器,所以上述当多个ACTION_MOVE事件到来时,这个onInterceptTouchEvent并不会调用多次,这个必须要记住。

那么现在我们再来分析一下主流程,还是看上面代码中的注释:

  • 注释1,当onScroll函数被触发时,其中滑动的方向是分X轴和Y轴方向,其中从右下到左上时,distanceX和distanceY为正数,即开始坐标减去结束坐标。

这里还必须要记录上次滑动的距离,包括正负号,这是因为在后面判断阈值时需要用到,等会细说。

最后一点就是,当onScroll函数触发时,速度必须置为0,不能认为是Fling,因为Fling必须是有ACTION_UP事件。

  • 注释2,其中_swipeFlags表示当前手势滑动的方向,而且当为0时才进行赋值,这个很关键。因为本章所说的侧滑菜单,当滑动时,只能向着一个方向滑动,而不能同时既左右方向又上下方向移动,只承认开始的方向。

比如下图:

image.png

刚开始先左滑一点点,再往下滑动,这时整个事件流就只认为是左滑事件。

  • 注释3,获取该ViewHolder的侧滑菜单宽度,以及范围,其中clamp方法在之前我们介绍过。

  • 注释4,即事件流的滑动方向是水平方向,且该ViewHolder包含LEFT或者RIGHT,其中依旧要判断一下_scrollX的正负值与设置的Flag是否冲突,如果冲突则设置_scrollX为0。

  • 注释5,这个判断很关键,即当事件流为左右方向时,该ViewHolder不具备左右方向,或者设置的上下方向,这时的逻辑是不处理、不拦截,事件可以正常传递给RecyclerView。

最后调用onSwipeTo来位移ViewHolder,这个方法在前面我们介绍过。

好了,onScroll函数中的逻辑处理已经说完了,关键点就是根据需求,来设置当前滑动事件的Flags和ViewHolder设置的Flag进行处理和比较。

现在我们回到主线,当ACTION_MOVE来到onInterceptTouchEvent时,为了方便,我们传入给手势识别器,这时针对手势的方向和Viewholder设置的方向进行判断,当需要拦截时,则把_needHandleTouch设置为true,表示该前置拦截器要拦截该事件流;否则设置为false,表示不拦截,即该scroll事件会传递给RecyclerView。

比如下图:

11 (1).gif

在该RecyclerView中,我给Item设置了一个横向滚动布局,所以当左右滑动第一个Item时,前置拦截器会拦截滑动事件,进行显示侧滑菜单;而左右滑动第二个Item时,则是滚动Item自己的布局,即前置拦截器没有拦截事件。

我们接着回到主线,当多个ACTION_DOWN到来时,我们根据需求,进行拦截,一旦进行拦截,该事件流都会被拦截,即会调用拦截器中的onTouchEvent方法,我们看一下该方法:

//OnItemTouchListener#onTouchEvent
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
    Log.i(TAG, "onTouchEvent: $e")
    if (_needHandleTouch) {
        gestureDetectorCompat?.onTouchEvent(e)
    }
    val actionMasked = e.actionMasked
    //注释,特别处理
    if (actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_CANCEL) {
        touchFinish()
    }
}

可以发现对于ACTION_MOVE事件的处理,也是交给手势识别器来处理,而这里的处理也就是移动ViewHolder,我们无需赘述。

好了,到这里我们就来处理最后一步,即ACTION_UP事件,再次回想一下事件流,其中单击事件由ACTION_DOWN加ACTION_UP组成,而滑动和Fling事件中间会有多个ACTION_MOVE事件,所以当是需要处理的单击事件时,我们的做法是一律不拦截,单击事件由RecyclerView自己处理。

而实现这个逻辑就是我们前面所说的SimpleOnGestureListener类,其中对于单击的回调函数返回false,即不拦截。

那么现在就来处理当手指滑动了某个ViewHolder,而且该ViewHoler是有设置滑动Flags的情况,即上面代码的注释了特殊处理的地方,这里是处理事件流结束的关键之处。

话不多说,我们接着分析,下面是该函数代码:

fun touchFinish() {
    if (_needHandleTouch) {
        val downViewHolder = _downViewHolder
        val recyclerView = _recyclerView
        if (recyclerView != null && downViewHolder != null) {
            val swipeFlag =
                swipeMenuCallback.getMovementFlags(recyclerView, downViewHolder)
            val swipeThreshold =
                swipeMenuCallback.getSwipeThreshold(recyclerView, downViewHolder)
            val swipeMaxWidth =
                swipeMenuCallback.getSwipeMenuMaxWidth(recyclerView, downViewHolder)
                    .toFloat()
            val swipeMaxHeight =
                swipeMenuCallback.getSwipeMenuMaxHeight(recyclerView, downViewHolder)
                    .toFloat()
            val swipeWidthThreshold = swipeMaxWidth * swipeThreshold
            val swipeHeightThreshold = swipeMaxHeight * swipeThreshold
            Log.i(TAG, "touchFinish: 侧滑菜单宽度 $swipeMaxWidth  宽度阈值$swipeWidthThreshold")
            val swipeVelocityXThreshold = swipeMenuCallback.getSwipeVelocityThreshold(
                recyclerView,
                downViewHolder,
                _lastVelocityX
            )
            val swipeVelocityYThreshold = swipeMenuCallback.getSwipeVelocityThreshold(
                recyclerView,
                downViewHolder,
                _lastVelocityY
            )
            //注释1,上面获取ViewHolder的配置
            if (_swipeFlags == SwipeMenuCallback.FLAG_HORIZONTAL) {
                if (_lastVelocityX != 0f && _lastVelocityX.absoluteValue >= swipeVelocityXThreshold) {
                    //注释2,Fling处理
                    Log.i(TAG, "touchFinish: 左右最后是Fling状态")
                    if (_scrollX < 0 && _lastVelocityX < 0 && swipeFlag.have(LEFT)) {
                        Log.i(TAG, "touchFinish: 向左快速Fling")
                        scrollSwipeMenuTo(downViewHolder, -swipeMaxWidth, 0f)
                    } else if (_scrollX > 0 &&
                        _lastVelocityX > 0 &&
                        swipeFlag.have(RIGHT)
                    ) {
                        Log.i(TAG, "touchFinish: 向右快速Fling")
                        scrollSwipeMenuTo(downViewHolder, swipeMaxWidth, 0f)
                    } else {
                        Log.i(TAG, "touchFinish: 关闭")
                        closeSwipeMenu(downViewHolder)
                    }
                } else {
                    //注释3,onScroll处理
                    Log.i(TAG, "touchFinish: 左右最后是Scroll")
                    if (_scrollX < 0) {
                        Log.i(TAG, "touchFinish: ViewHolder已经左滑 _scrollX = $_scrollX _lastDistance = $_lastDistanceX")
                        Log.i(TAG, "touchFinish: ViewHolder已经左滑 阈值是$swipeWidthThreshold  反向阈值是${swipeWidthThreshold - swipeMaxWidth}")
                        if ((_lastDistanceX > 0 && _scrollX.absoluteValue >= swipeWidthThreshold) ||
                            (_lastDistanceX < 0 && (swipeMaxWidth + _scrollX) < swipeWidthThreshold)
                        ) {
                            //意图打开右边的菜单
                            scrollSwipeMenuTo(downViewHolder, -swipeMaxWidth, 0f)
                        } else {
                            //关闭菜单
                            closeSwipeMenu(downViewHolder)
                        }
                    } else if (_scrollX > 0) {
                        if ((_lastDistanceX < 0 && _scrollX.absoluteValue >= swipeWidthThreshold) ||
                            (_lastDistanceX > 0 && (swipeMaxWidth - _scrollX) < swipeWidthThreshold)
                        ) {
                            //意图打开左边的菜单
                            scrollSwipeMenuTo(downViewHolder, swipeMaxWidth, 0f)
                        } else {
                            //关闭菜单
                            closeSwipeMenu(downViewHolder)
                        }
                    }
                }
            } else if (_swipeFlags == SwipeMenuCallback.FLAG_VERTICAL) {
                //省略
            }
        }
    }

    _downViewHolder = null
    _needHandleTouch = true
    _swipeFlags = 0
    _recyclerView?.parent?.requestDisallowInterceptTouchEvent(false)
}

上面代码乍一看非常多,但却是必须要掌握的一些知识点,尤其对Fling不熟悉的开发者来说,我们还是一步步分析一波,主要就是代码中的3个注释:

  • 注释1,获取ViewHolder的配置项,包括滑动Flag、菜单宽度等,其中有个非常重要的就是滑动阈值和速度阈值,之前我们定义SwipeMenuCallback时说过,滑动阈值默认0.3,假如菜单宽度200,滑动阈值就是60,这个很重要;其次就是速度阈值,这个我们一般不做限制,就是Fling时的速度。

  • 注释2,当前手势的滑动是左右方向(这个在onScroll中我们已经判断),且水平方向的速度不为空,且进行了水平方向移动,这时我们就认为是Fling状态。这里或许会问,这个速度我们是咋判断的,还是手势识别器中的onFling回调:

override fun onFling(
    e1: MotionEvent?,
    e2: MotionEvent?,
    velocityX: Float,
    velocityY: Float
): Boolean {
    _lastVelocityX = velocityX
    _lastVelocityY = velocityY
    return super.onFling(e1, e2, velocityX, velocityY)
}

而这里的Fling效果处理就比较值得学习,比如ViewHolder可以向左侧滑,而这时手指快速左滑,即使只滑动了一点点距离,依旧可以侧滑开菜单,这就给人一种有"惯性"的感觉,而这种Fling加"惯性"的效果,在列表滑动时很多APP都有,可以让APP显得更加丝滑。

这里比如向左快速Fling,调用scrollSwipeMenuTo方法,在这个方法中我们需要给滑动加个动画,因为这里不能很突兀地就改变其translationX的值,效果会跳一下,不够丝滑,函数代码如下:

fun scrollSwipeMenuTo(viewHolder: RecyclerView.ViewHolder, x: Float = 0f, y: Float = 0f) {
    if (_lastValueAnimator?.isRunning == true) {
        return
    }
    _recyclerView?.apply {
        val startX = _scrollX
        val startY = _scrollY
        if (x != 0f || y == 0f) {
            //将要打开的菜单
            _swipeMenuViewHolder = viewHolder
        }
        val valueAnimator = ValueAnimator.ofFloat(0f, 1f)
        valueAnimator.addUpdateListener {
            val fraction: Float = it.animatedValue as Float
            val currentX = startX + (x - startX) * fraction
            val currentY = startY + (y - startY) * fraction

            _scrollX = currentX
            _scrollY = currentY

            swipeMenuCallback.onSwipeTo(_recyclerView!!, viewHolder, currentX, currentY)
        }
        valueAnimator.addListener(onEnd = {
            if (x == 0f && y == 0f) {
                //关闭菜单
                _swipeMenuViewHolder = null
            } else {
                _swipeMenuViewHolder = viewHolder
            }
            _lastValueAnimator = null
        }, onCancel = {
            _lastValueAnimator = null
        })
        valueAnimator.duration =
            ItemTouchHelper.Callback.DEFAULT_SWIPE_ANIMATION_DURATION.toLong()
        valueAnimator.start()
        _lastValueAnimator = valueAnimator
    }
}

这里代码很简单,就是利用属性动画在一定时间内,让ViewHolder移动到预定位置,这种思路写法在我们平时APP中对于View的移动尽量都做到加点动画,避免移动的太突兀。

  • 注释3,这里是对scroll的处理,乍一看咋这么多代码,其实里面也包含了一些交互的细节。就比如注释3的位置手势是水平方向滑动,当_scrollX小于0时,说明ViewHolder已经向左边滑动了,但是这里依旧有2个判断都可以打开右边的侧滑菜单,我们来细节看一下。

第一个是:

(_lastDistanceX > 0 && _scrollX.absoluteValue >= swipeWidthThreshold)

其中_lastDistanceX大于0表示上一次滑动是左滑,并且ViewHolder滑动的距离大于阈值,这时打开菜单,逻辑是正常的,就比如下面动图:

12.gif

这里我一直左滑,当滑动距离大于阈值(菜单宽度*0.3)便可以自动打开侧滑菜单。

第二个是:

(_lastDistanceX < 0 && (swipeMaxWidth + _scrollX) < swipeWidthThreshold)

其中_lastDisnanceX小于0说明上一次滑动是右滑,注意这里是上一次滑动,而上一次滑动的方向和距离是在onScroll方法中记录,而且ViewHolder是以及左滑的状态,这时如果想打开菜单,超过0.3阈值就不行了,必须要大于0.7,这个细微操作也是符合人们正常操作的,比如下面动图:

13.gif

比如上图中,我是先左滑一部分距离,然后再右滑,在手指离开屏幕的前一小段滑动是右滑,但是这时ViewHolder肯定滑动的偏移量大于0.3了,这时是不能给展开的。

就像是人们的心里:虽然我滑开了这个菜单,但是突然又不想滑开了,这时你要加强这个用户,当是这种情况时,必须已经滑开的偏移量大于0.7才让打开。

小结

到这里,已经完全说完了自带侧滑菜单的ViewHolder的实现方式,其实这里我们有非常多的可以值得学习的地方,比如事件拦截的细节手势拦截器的使用Fling状态以及scroll状态的微操,这些都可以借鉴到我们其他项目中去。

ItemDecoration实现方式

在前面学习很多前置知识后,再来看看用RecyclerView的ItemDecoration方式实现的侧滑菜单就比较容易了。

如何绘制侧滑菜单

这里我们还是采用拦截事件加处理ViewHolder的方法,其中拦截事件就不具体说了,在前面一种实现方法里我们已经说过了,现在我们来思考一下如何绘制侧滑菜单。

比如下图,我们在拦截事件后依旧移动ViewHolder:

image.png

由于ViewHolder移动走了,在虚线红框内将没有内容,所以是一片空白,我们现在想在虚线框内绘制侧滑按钮,这时我们知道可以利用RecyclerView的ItemDecoration在该空白区域绘制侧滑菜单。

在前面的介绍我们了解到ItemDecoration的onDraw方法会在ItemView绘制之前绘制,其调用是在RecyclerView的onDraw()方法中,所以解决方案就是在滑动时不断的调用RecyclerView的invalidate()方法即可

问题点

很多代码我们可以复用上面的实现方式,但是还有很多问题需要来解决,现在有如下额外问题:

  • 点击事件如何处理,因为侧滑菜单在ViewHolder中时,对侧滑菜单的点击事件就是ViewHolder中子View的点击事件;在本方法中,这块绘制的区域和ViewHolder是分开的,我们无法通过设置RecyclerView的Item的点击事件来进行。

解决方案也是非常简单粗暴,我们直接在绘制的View中保存该区域信息,因为是通过不断onDraw()方法绘制的,所以其区域信息也是不变的。

  • 侧滑菜单按钮如何设计,因为不像侧滑菜单在ViewHolder中的情况,我们使用起来非常方便,本章内容为了简洁,就设计了一种侧滑按钮的实现,其他实现可以根据项目具体需求改变。

具体实现

首先还是事件拦截和处理,我们具体细节在前面已经说过了,我们只需要做的就是在调用onSwipeTo函数来移动ViewHolder的地方来刷新recyclerView即可,即:

_recyclerView?.invalidate()

然后根据前面所说的ItemDecoration原理,我们需要在给recyclerView添加ItemDecoration:

private fun setupCallbacks() {
    _recyclerView?.apply {
        val vc = ViewConfiguration.get(context)
        _slop = vc.scaledTouchSlop
        //添加ItemDecoration
        addItemDecoration(this@ItemDecorationSwipeMenuHelper)
        addOnItemTouchListener(mOnItemTouchListener)
        addOnChildAttachStateChangeListener(this@ItemDecorationSwipeMenuHelper)
        startGestureDetection()
    }
}

这里我们即可以在onDraw回调中来绘制我们的侧滑菜单:

override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
    val viewHolder = if (_downViewHolder != null) _downViewHolder else _swipeMenuViewHolder
    Log.i(TAG, "onDraw: position = ${viewHolder?.absoluteAdapterPosition}")
    val position = viewHolder?.absoluteAdapterPosition
    //注释1,获取正在操作的ViewHolder
    position?.let {
        //注释2,获取侧滑按钮
        val swipeButtons = itemDecorationSwipeMenuCallback.getSwipeButtons(_recyclerView!!,viewHolder)
        val swipeButtonWidth = itemDecorationSwipeMenuCallback.width
        val itemView = viewHolder.itemView
        if (_scrollX < 0){
            //绘制右侧侧滑菜单
            //获取ViewHolder右侧坐标
            var right = itemView.right.toFloat()
            Log.i(TAG, "onDraw: right = $right")
            //绘制每个按钮
            //注释3,调用自定义按钮的绘制方法
            for (rightBtn in swipeButtons!!){
                val left = right + _scrollX
                rightBtn.onDraw(c, RectF(left,itemView.top.toFloat(),right,itemView.bottom.toFloat()),it)
            }
        }
    }
}

该方法会在我们滑动ViewHolder的时候进行回调,由于该方法中,ViewHolder中将没有侧滑菜单,所以我们在移动ViewHolder时将不会再特殊区分第1个侧滑菜单容器,这样可以减小对原RecyclerView的侵入性。我们还是来简单分析一下上述代码:

  • 注释1,获取正在操作的ViewHolder,这里延续了前面的代码,没有做过多细节处理,所以_downViewHolder不为空时则是_downViewHolder,否则是_swipeMenuViewHolder;
  • 注释2,获取每个ViewHolder对应的侧滑按钮,这个就和前面实现的不一样了,前面方法的侧滑菜单是直接在ViewHolder中的,是在RecyclerView的Adapter中进行配置;而本方法中,我们需要自定义一个特殊的侧滑按钮控件,该控件代码如下:
class SwipeButton(
    private val buttonText: String = "测试",
    private val buttonColor: Int = Color.parseColor("#5297FF"),
    private val textColor: Int = Color.parseColor("#FFFFFF"),
    private val clickListener: ((Int) -> Unit)? = null) {

    //当前按钮可以点击的范围,这里默认其实就是Button的范围
    var clickRegion: RectF? = null
    //当前按钮所绑定的RecyclerView的position
    private var position: Int = 0

    //点击事件是否在按钮点击范围内,这个由RecyclerView的点击事件会调用
    fun onClick(x: Float, y: Float): Boolean {
        if (clickRegion != null && clickRegion?.contains(x, y)!!) {
            clickListener?.invoke(position)
            return true
        }
        return false
    }

    //绘制,这里因为按钮比较简单,所以直接使用代码绘制
    //参数canvas和rectF都是第三方调用者传入
    fun onDraw(canvas: Canvas, rectF: RectF, position: Int) {
        val paint = Paint()
        //设置按钮背景颜色
        paint.color = buttonColor
        canvas.drawRect(rectF, paint)
        //设置按钮文本颜色
        paint.color = textColor
        //设置文字大小
        paint.textSize = 18 * Resources.getSystem().displayMetrics.density
        paint.textAlign = Paint.Align.LEFT

        //在rectF上绘制文本
        val tempRect = Rect()
        val height = rectF.height()
        val width = rectF.width()
        paint.getTextBounds(buttonText, 0, buttonText.length, tempRect)
        val x = width / 2f - tempRect.width() / 2f - tempRect.left
        val y = height / 2f + tempRect.height() / 2f - tempRect.bottom
        canvas.drawText(buttonText, rectF.left + x, rectF.top + y, paint)
        //记录点击区域,这里这样记录点击区域的话,也就是在每次RecyclerView按钮绘制时都是最新的
        clickRegion = rectF
        this.position = position
    }
}

可以发现这里有个clickRegion变量专门用来保存当前该View的区域,其次就是onClick方法,这个参数就是我们单击在RecyclerView上时的坐标,当该坐标在该保存的区域上时,则响应点击事件。

通过这个巧妙的办法,我们就解决了第一个大难题,即点击事件如何处理。我们直接记录侧滑菜单的区域,然后当点击事件发生时,判断该点击事件是否在侧滑区域内,完成点击事件的响应。

其次就是该侧滑按钮的绘制了,我们使用不断绘制的方式,在onDraw中,我们根据ViewHolder的滑动距离以及方向,传入canvas和Rect,具体见注释3和SwipeButton的onDraw方法。

完成上面代码后,我们便可以实现在滑动时绘制侧滑按钮了,效果如下:

15.gif

更多细节代码可以去查看代码,本篇文章主要就是理解这个思路。

总结

先说一下这2种方式实现的侧滑的优缺点吧,使用自带侧滑菜单的ViewHolder这种方式实现,逻辑比较简单,点击事件比较好处理,缺点就是要修改本来RecyclerView的Item的布局;而使用ItemDecoration方式实现,优点就是完全不用改变原来RecyclerView的任何布局,但是对于绘制内容和处理点击事件要求较高,对于复杂点的侧滑菜单效果,实现起来比较麻烦。

然后是本篇文章,本来是不准备写这么长的,但是由于涉及的知识点比较多,就是能说的都简单说一下,其中还是不妨有许多我们平时开发中忽略的细节或者不擅长的地方,后面有时间再整理一下本篇文章和代码。

欢迎大家点赞,评论,共同进步。

猜你喜欢

转载自juejin.im/post/7114557569561001992