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

简介

现象

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

代码

代码比较简单,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绝对值比较的判断。只有当手指横向移动的距离大于纵向移动的距离,我们才去走原本的拦截逻辑。

效果

方案1

优点

只需重写父控件的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;
        }
    }
}
效果

方案2

优点
  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
可回退到我修改前的代码自行尝试分析,更便于深入理解

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值