欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

事件分发典型bug:RecycleView滑动嵌套问题解决

程序员文章站 2022-04-07 20:30:18
简介现象在工作中碰到了一个易用性的问题,当一个横向滑动的HorizonRecycleView(注意这里只是一个普通的加了日志打印的RecycleView,并没有改动其自身逻辑),每个Item都包含了一个纵向滑动的VerticalRecycleView(同上)时,若此时想去滑动纵向的VerticalRecycleView,很容易触发到HorizonRecycleView的横向滑动。可能说起来有点绕,直接看图可能更明显点。代码代码比较简单,A与B都使用的是LinearLayoutManager,这里展...

简介

现象

在工作中碰到了一个易用性的问题,当一个横向滑动的HorizonRecycleView(注意这里只是一个普通的加了日志打印的RecycleView,并没有改动其自身逻辑),每个Item都包含了一个纵向滑动的VerticalRecycleView(同上)时,若此时想去滑动纵向的VerticalRecycleView,很容易触发到HorizonRecycleView的横向滑动。可能说起来有点绕,直接看图可能更明显点。
事件分发典型bug:RecycleView滑动嵌套问题解决

代码

代码比较简单,A与B都使用的是LinearLayoutManager,这里展示一下他们item的layout文件

HorizonRecycleView的item

每个item左边是一个TextView,右边是一个VerticalRecycleView

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="400dp"
    android:background="@android:color/holo_blue_light"
    android:layout_marginEnd="20dp"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/tv_title_horizon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Item"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@id/rv_vertical"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.kyrie.proj.blog.nestedscroll.VerticalRecycleView
        android:id="@+id/rv_vertical"
        android:layout_width="200dp"
        android:layout_height="match_parent"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

VerticalRecycleView的item

只有一个TextView

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:layout_marginBottom="20dp"
    android:background="@android:color/holo_green_light">


    <TextView
        android:id="@+id/tv_title_vertical"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Item"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

问题分析

日志分析

我们把两个RecycleView的dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent都加上打印,来分别比较一下正常滑动VerticalRecycleView和误触发了HorizonRecycleView滑动的日志有什么区别

正常竖直滑动

//ACTION_DONW事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false //HorizonRecycleView不强制拦截
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] return false //VerticalRecycleView不强制拦截
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费此事件
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_DONW事件分发结束,被VerticalRecycleView消费

//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费此ACTION_MOVE事件
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_MOVE事件分发结束

//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
//...
//上面省略N个MOVE事件分发

//ACTION_UP事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_UP
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_UP
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_UP
I/wzt: [VerticalRecycleView][onTouchEvent] return true
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//事件分发流程结束

误触发了横向滑动

//ACTION_DONW事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_DOWN
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//ACTION_DONW事件分发结束,流程与正常情况完全一致

//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return false
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [VerticalRecycleView][onTouchEvent] return true //VerticalRecycleView消费
//ACTION_MOVE事件分发结束

//ACTION_MOVE事件分发
//...
//上面省略了大概5个MOVE事件分发,都和正常竖直滑动时一致

//注1:注意注意注意啦!!!:从这里开始就是重头戏
//ACTION_MOVE事件分发
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onInterceptTouchEvent] return true //注2:这里直接被HorizonRecycleView拦截
//事件被父控件拦截,导致VerticalRecycleView只能收到一个ACTION_CANCEL事件
I/wzt: [VerticalRecycleView][dispatchTouchEvent] ev = ACTION_CANCEL 
I/wzt: [VerticalRecycleView][onTouchEvent] e = = ACTION_CANCEL
I/wzt: [VerticalRecycleView][onTouchEvent] return true
I/wzt: [VerticalRecycleView][dispatchTouchEvent] return true 
//VerticalRecycleView消费了ACTION_CANCEL事件之后,此次滑动序列再也没有收到任何事件
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
//之后的所有MOVE事件,不会再走onInterceptTouchEvent方法,直接交给HorizonRecycleView消费
I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true

//ACTION_MOVE事件分发开始
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_MOVE
I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_MOVE
I/wzt: [HorizonRecycleView][onTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true
//...
//省略N个MOVE事件分发

//ACTION_UP事件分发,与正常现象一致
I/wzt: [HorizonRecycleView][dispatchTouchEvent] ev = ACTION_UP
I/wzt: [HorizonRecycleView][onTouchEvent] e = = ACTION_UP
I/wzt: [HorizonRecycleView][onTouchEvent] return true
I/wzt: [HorizonRecycleView][dispatchTouchEvent] return true

日志分析总结

通过如上两个日志对比我们发现,出现问题的原因在于<注2>部分,HorizonRecycleView拦截了一次MOVE事件,导致VerticalRecycleView后续除了一个CANCEL外无法收到任何事件。

ACTION_CANCEL

这里稍微提一下我一直都没有理解的ACTION_CANCEL,从上面的日志我们就可以了解到ACTION_CANCEL出现的场景:当一个View在消费一个事件序列的过程中,父控件拦截了此次事件(父控件onInterceptTouchEvent返回true),这个View就会收到一个ACTION_CANCEL,并且View在此时进行内部状态的重置,如从常态恢复成点击态。并且此次事件序列的后续事件都会直接交给父控件处理。

原因

从日志分析可得横向滑动的误触发是由于HorizonRecycleView的事件拦截引起,那么直接到RecycleView源码里分析一下为何会在MOVE过程中拦截。注意下面的源码省略了非关键的部分

//RecycleView.java
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    final int action = e.getActionMasked();
    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            //在DOWN时记录手指点击的区域
            //这里加0.5f的原因是为了转成int值时四舍五入
            mInitialTouchX = (int) (e.getX() + 0.5f);
            mInitialTouchY = (int) (e.getY() + 0.5f);
        }
        case MotionEvent.ACTION_MOVE: {
            final int x = (int) (e.getX(index) + 0.5f);
            final int y = (int) (e.getY(index) + 0.5f);
            //当前不是拖动状态则进行判断
            if (mScrollState != SCROLL_STATE_DRAGGING) {
                //算出手指移动的距离
                final int dx = x - mInitialTouchX;
                final int dy = y - mInitialTouchY;
                boolean startScroll = false;
                //注1:能横向滚动并且手指移动的距离大于mTouchSlop
                //这个mTouchSlop是在RecycleView初始化时确定的滑动临界值,大于这个值就从静止切换为滑动状态
                if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                    //这里标志位为true
                    startScroll = true;
                }
                //竖直方向,效果同上
                if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                    startScroll = true;
                }
                if (startScroll) {
                    //方法内部会把mScrollState置为SCROLL_STATE_DRAGGING
                    setScrollState(SCROLL_STATE_DRAGGING);
                }
            }
        }
    }
    //若为SCROLL_STATE_DRAGGING状态则return true拦截事件
    return mScrollState == SCROLL_STATE_DRAGGING;
}

从上面的源码<注1>可以看到,在MOVE事件中,若当前手指在HorizonRecycleView横向的滑动大于滑动临界值,则HorizonRecycleView 会直接不去判断其它任何条件置为滑动状态,直接拦截此事件。这就是问题根本原因所在了,HorizonRecycleView只是判断手指在x轴的移动距离超过了临界值就直接强行拦截后续事件。

解决方案

知道了问题原因,解决方案很明显就是如何让HorizonRecycleView不去拦截此次MOVE事件呢。有两种方法

  1. 重写HorizonRecycleView的onInterceptTouchEvent方法逻辑,修改判断切换滑动状态的部分
  2. 通过内部拦截法

方案1:重写HorizonRecycleView的onInterceptTouchEvent逻辑

方案来自于 修复RecyclerView嵌套滚动问题,在大佬基础上有少量简化

直接在BetterRecyclerView照着RecycleView源码重写onInterceptTouchEvent,用BetterRecyclerView代替HorizonRecycleView原本的位置即可

//BetterRecyclerView.java
public class BetterRecyclerView extends RecyclerView{
    private static final int INVALID_POINTER = -1;
    private int mScrollPointerId = INVALID_POINTER;
    private int mInitialTouchX, mInitialTouchY;
    private int mTouchSlop;
    public BetterRecyclerView(Context context) {
        this(context, null);
    }

    public BetterRecyclerView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public BetterRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        final ViewConfiguration vc = ViewConfiguration.get(getContext());
        mTouchSlop = vc.getScaledTouchSlop();
    }

    @Override
    public void setScrollingTouchSlop(int slopConstant) {
        super.setScrollingTouchSlop(slopConstant);
        final ViewConfiguration vc = ViewConfiguration.get(getContext());
        switch (slopConstant) {
            case TOUCH_SLOP_DEFAULT:
                mTouchSlop = vc.getScaledTouchSlop();
                break;
            case TOUCH_SLOP_PAGING:
                mTouchSlop = vc.getScaledPagingTouchSlop();
                break;
            default:
                break;
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        LayoutManager mLayout = getLayoutManager();
        if (mLayout == null) {
            return false;
        }

        final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
        final boolean canScrollVertically = mLayout.canScrollVertically();

        final int action = e.getActionMasked();
        final int actionIndex = e.getActionIndex();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = (int) (e.getY() + 0.5f);
                return super.onInterceptTouchEvent(e);

            case MotionEvent.ACTION_POINTER_DOWN:
                mScrollPointerId = e.getPointerId(actionIndex);
                mInitialTouchX = (int) (e.getX(actionIndex) + 0.5f);
                mInitialTouchY = (int) (e.getY(actionIndex) + 0.5f);
                return super.onInterceptTouchEvent(e);

            case MotionEvent.ACTION_MOVE: {
                final int index = e.findPointerIndex(mScrollPointerId);
                if (index < 0) {
                    return false;
                }

                final int x = (int) (MotionEventCompat.getX(e, index) + 0.5f);
                final int y = (int) (MotionEventCompat.getY(e, index) + 0.5f);
                if (getScrollState() != SCROLL_STATE_DRAGGING) {
                    final int dx = x - mInitialTouchX;
                    final int dy = y - mInitialTouchY;
                    boolean startScroll = false;
                    //注1:注意这里,在原本的基础上加入了dx>dy的判断
                    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop && Math.abs(dx) >= Math.abs(dy)) {
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop && Math.abs(dy) >= Math.abs(dx)) {
                        startScroll = true;
                    }
                    return startScroll && super.onInterceptTouchEvent(e);
                }
                return super.onInterceptTouchEvent(e);
            }

            default:
                return super.onInterceptTouchEvent(e);
        }
    }
}

从上面的代码<注1>看到,在原本的基础上加入了dx与dy绝对值比较的判断。只有当手指横向移动的距离大于纵向移动的距离,我们才去走原本的拦截逻辑。

效果

事件分发典型bug:RecycleView滑动嵌套问题解决

优点

只需重写父控件的onInterceptTouchEvent

缺点

由于重写时简化了RecycleView的onInterceptTouchEvent逻辑,移除了一些其他判断条件,可能存在特殊情况下的隐藏风险(目前暂未发现)

方案2:通过内部拦截法

内部拦截法步骤如下:

  1. 外部HorizonRecycleView拦截ACTION_DOWN以外的其它事件(ACTION_DOWN若拦截了会导致子控件无法收到任何焦点)
  2. 内部VerticalRecycleView在ACTION_DOWN时调用requestDisallowInterceptTouchEvent(true)不允许父控件拦截,即之后MOVE事件都不会走外部HorizonRecycleView的拦截逻辑
  3. 内部VerticalRecycleView在ACTION_MOVE时判断,若自己不需要滑动,则调用requestDisallowInterceptTouchEvent(false)重新走父控件HorizonRecycleView的拦截逻辑
    代码实现如下
class HorizonRecycleView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {

    override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
        //若不调用onInterceptTouchEvent,直接返回true或false会导致滑动的瞬间瞬移或首次无法横移的问题。
        var result = super.onInterceptTouchEvent(e)
        when (e.action) {
            MotionEvent.ACTION_DOWN ->{
                result = false
            }
        }
        return result
    }
}
class VerticalRecycleView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
    var downX = 0f
    var downY = 0f

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        Log.i("wzt", "[VerticalRecycleView][dispatchTouchEvent] ev = ${MotionEvent.actionToString(ev!!.action)}")
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = ev.x
                downY = ev.y
                Log.i("wzt","[VerticalRecycleView][dispatchTouchEvent]不允许父控件拦截")
                getParentRecycleView()?.requestDisallowInterceptTouchEvent(true)
            }
            MotionEvent.ACTION_MOVE -> {
                val currentX = ev.x
                val currentY = ev.y
                val x = abs(currentX - downX)
                val y = abs(currentY - downY)
                if (y < x) {
                    //表示我不需要消费此事件
                    Log.i("wzt","允许拦截")
                    getParentRecycleView()?.requestDisallowInterceptTouchEvent(false)
                }
            }
        }
        val result = super.dispatchTouchEvent(ev)
        Log.i("wzt", "[VerticalRecycleView][dispatchTouchEvent] return $result")
        return result
    }

    /**
     * 返回父RecycleView,这里直接往上级最高三层查找
     */
    private fun getParentRecycleView() :RecyclerView? {
        return when {
            parent is RecyclerView -> parent as RecyclerView
            parent.parent is RecyclerView -> parent.parent as RecyclerView
            parent.parent.parent is RecyclerView -> parent.parent.parent as RecyclerView
            else -> null
        }
    }
}

使用此方法需要注意:

  1. 子控件的判断逻辑需要放在dispatchTouchEvent或onTouchEvent中,因为若自己消费了事件,自身的onInterceptTouchEvent不会再被调用
  2. 父控件需要调用super.onInterceptTouchEvent(e),若不调用会导致mInitialTouchX得不到初始化,从而在之后move走到如下流程中无法消费事件,导致无法滑动
@Override
public boolean onTouchEvent(MotionEvent e) {
    case MotionEvent.ACTION_MOVE: {
        final int index = e.findPointerIndex(mScrollPointerId);
        if (index < 0) {
            Log.e(TAG, "Error processing scroll; pointer index for id "
                    + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
            return false;
        }
    }
}

效果

事件分发典型bug:RecycleView滑动嵌套问题解决

优点

  1. 逻辑交给子控件自行处理,可操作性更高
  2. 可以在一个事件序列中先内部竖直滑动,再外部横向滑动

缺点

  1. 改动的类更多
  2. 需要注意的点较多

废弃方案:外部拦截法

通过在MOVE时判断x轴和y轴的移动距离来判断是否需要拦截

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            downX = event.getX();
            downY = event.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            float currentX = event.getX();
            float currentY = event.getY();
            float x = Math.abs(currentX - downX);
            float y = Math.abs(currentY - downY);
            return x < y;
    }
    return super.onInterceptTouchEvent(event);
}

此方案为我最开始使用的方案,但是某个机型的mTouchSlop(滑动临界值)过小,导致若HorizonRecycleView的每个Item除了VerticalRecycleView之外若还有Button之类的控件。很容易触发onInterceptTouchEvent的return ture条件,从而拦截了Item上Button的touch事件,导致Button很难被点击到

总结

之前对事件分发机制一直理解比较模糊,在仔细通过日志、源码分析了这次的滑动嵌套问题后,的确学到了很多。但是RecycleView以及事件分发相关源码肯定不仅仅是我所描述的这么简单,如果文章中有写错的地方欢迎指出,有疑问的地方也欢迎交流~谢谢啦

测试工程链接:https://github.com/wangzici/blog
可回退到我修改前的代码自行尝试分析,更便于深入理解

本文地址:https://blog.csdn.net/wangzici/article/details/107479414