【Android 实验室】ViewPager2 横向滑动冲突的研究

6,500 阅读6分钟

摘要|Abstract
本文通过解决一个 bug 的过程逐步深入,得出关于滑动冲突的处理思路,展示了解决问题的思考过程以及深入源码的学习和探索过程。

关键词|Keyword
事件分发、滑动冲突、RecyclerView、ViewPager2

零、起源

滑动冲突是开发中比较常见的问题,不算复杂,但偶尔会让人措手不及。滑动冲突的起源是触摸事件的分发过程中没有满足开发者的需求,最终处理触摸事件的 View 不是业务上想要的。

ViewPager 和 RecyclerView 是滑动冲突问题多发区域,今天的问题就发生在 RecyclerView 中的 ViewPager 里。

一、问题描述

问题产生于这样的布局结构:

-- ViewPager2(横向)
  -- ……
    -- RecyclerView(纵向)
      -- ……
        -- ViewPager2(横向)

不作任何处理的情况下,最内层的 ViewPager2 无法滑动,所有的横向滑动事件都被交给外层的 ViewPager2 处理了。第一个问题就是如何解决这个滑动冲突,具体的需求是「当内部的 ViewPager2 还有内容可滑动的时候,滑动事件交由内部 ViewPager2 处理;当内部 ViewPager2 不再能滑动时,滑动事件交由外部 ViewPager2 处理」。

在复现问题的过程中,还发现了一些额外信息:使用以前的项目进行调试时,ViewPager 嵌套 ViewPager2 的情况下没有出现滑动冲突。于是产生了第二个问题,为什么 ViewPager 嵌套是没有冲突的?

接下来是探索和解决问题的过程,不想看啰嗦的同学可以快进到第三部分直接看结论。

二、逐层探索

先来复习一下 Android 的事件分发机制,直接上图:

【触摸事件基本分发图】

为了简化表达,下面的叙述中我们按功能给两个有冲突的 ViewPager2 取名字,把外层的 ViewPager2 称为 「Pager」,内部的 ViewPager2 称为「Banner」。

  • 推理一

触摸事件的处理发生在 onTouchEvent 里,根据最终的现象可以推知 Banner 的 onTouchEvent 没有被触发,但 Pager 的 onTouchEvent 触发了。而 Banner 位于 Pager 的上层,在没有打断的情况下 Banner 的 onTouchEvent 一定在 Pager 之前触发,所以是 Pager 使用 onInterceptTouchEvent 拦截了这一次滑动。

  • 求证一

要证明上述推理,需要确认 ViewPager2 中的 onInterceptTouchEvent 进行了优先拦截,即重写了 onInterceptTouchEvent,如果判断触摸事件为横向滑动,就由自身消耗掉这次触摸事件。

Tips:ViewPager2 是基于 RecyclerView 封装的,但继承自 ViewGroup,通过组合而非继承的方式获得 RecyclerView 的功能。这也导致了 ViewPager2 是 final 的,不能通过继承进行扩展。

追寻 ViewPager2 的源码可以得知,ViewPager2 对触摸事件拦截的处理与 RecyclerView 基本一致:

// in RecyclerViewImpl
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    return isUserInputEnabled() && super.onInterceptTouchEvent(ev);
}

那 RecyclerView 是不是默认拦截了触摸事件呢?根据经验,对滑动的判断需要 ACTION_DOWN 记录初始点并在 ACTION_MOVE 移动的时候进行对比判断以区分是否滑动和滑动的方向。

// in onInterceptTouchEvent, ACTION_MOVE
if (mScrollState != SCROLL_STATE_DRAGGING) {
    final int dx = x - mInitialTouchX;
    final int dy = y - mInitialTouchY;
    boolean startScroll = false;
    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
        mLastTouchX = x;
        startScroll = true;
    }
    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
        mLastTouchY = y;
        startScroll = true;
    }
    if (startScroll) { // 设置为拖动状态就会 return true,开始拦截触摸事件
        setScrollState(SCROLL_STATE_DRAGGING);
    }
}

再来运行代码验证一下吧,由于 ViewPager2 不能继承,我在 ViewPager2 外面包一层自定义的 FrameLayout,通过 FrameLayout 的触摸事件回调判断 ViewPager2 能接收到哪些事件,通过 Log 输出方法名和事件类型,结果如下:

完全没有触发 onTouchEvent,事件确实被拦截掉了。

Tips: ACTION_DOWN=0; ACTION_MOVE=2; ACTION_CANCEL=3;


知晓原因之后,就可以考虑处理方案了。

  • 推理二

由于事件拦截的询问是由根 View 向上的,Pager 和 Banner 有相同的 onInterceptTouchEvent 条件,所以 Pager 先拦截到了事件。要正确处理 ViewPager2 的嵌套滑动,必须干涉事件分发过程。

  • 推理三

ViewPager2 不可继承,干涉的方式只能是修改 Pager 和 Banner 之间的一个 Layout,当 Banner 能滑动时,禁止拦截触摸事件

  • 求证二、三

还用上面提过的自定义 FrameLayout 来做实验,修改在 dispatchTouchEvent 或者 onInterceptTouchEvent 中都可以做,但要注意不能让 onInterceptTouchEvent 返回 true,因为修改的是 Banner 的父View而不是 Banner 本身,一旦拦截事件也会导致 Banner 收不到事件。

具体的做法是:

  1. 判断触摸事件是否是滑动以及滑动的方向
  2. 判断子View是否能向该方向滑动
  3. 子View可滑动时,调用 requestDisallowInterceptTouchEvent(true) 禁止拦截事件

Tips: requestDisallowInterceptTouchEvent 是一个「会传递」的函数,调用时会逐步调用 parent 的 requestDisallowInterceptTouchEvent,所以这里不需要判断外层具体是什么。

代码实现如下:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    Log.e("asdfg", "dispatchTouchEvent ${ev?.action}")
    when (ev?.action) {
        MotionEvent.ACTION_DOWN -> {
            mInitialTouchX = ev.x
            mInitialTouchY = ev.y
        }
        MotionEvent.ACTION_MOVE -> {
            val dx = ev.x - mInitialTouchX
            val dy = ev.y - mInitialTouchY
            var hasScrollView = false
            for (i in 0 until childCount) {
                val child = getChildAt(i)
                if (child.canScrollHorizontally(-1) && dx > 0) {
                    hasScrollView = true
                }
                if (child.canScrollHorizontally(1) && dx < 0) {
                    hasScrollView = true
                }
            }
            val r = abs(dy) / abs(dx)
            if (r < 0.6f && hasScrollView) { // 比例可调整
                requestDisallowInterceptTouchEvent(true)
            }
        }
        MotionEvent.ACTION_UP -> {
            requestDisallowInterceptTouchEvent(false)
        }
        MotionEvent.ACTION_CANCEL -> {
            requestDisallowInterceptTouchEvent(false)
        }
    }

运行结果:

预期的滑动效果实现了。实验代码中 Banner 未设置无限滑动,是为了验证滑到尽头时的处理有没有异常。


问题解决了,回头再看一下第二个问题,ViewPager 里面做了什么处理避免了滑动冲突呢?

View 的层级结构如下:

-- ViewPager
  -- ConstraintLayout
    -- ViewPager2

水平滑动的时候,滑动事件基本上是由 ViewPager2 处理的,说明 ViewPager 中 onInterceptTouchEvent 判断拦截的条件是更加严格的,我们可以截取判断条件做一个对比:

// ViewPager
if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) {
    if (DEBUG) Log.v(TAG, "Starting drag!");
    mIsBeingDragged = true;
    requestParentDisallowInterceptTouchEvent(true);
    setScrollState(SCROLL_STATE_DRAGGING);
    mLastMotionX = dx > 0
            ? mInitialMotionX + mTouchSlop : mInitialMotionX - mTouchSlop;
    mLastMotionY = y;
    setScrollingCacheEnabled(true);
}

// ViewPager2
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
    mLastTouchX = x;
    startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
    mLastTouchY = y;
    startScroll = true;
}
if (startScroll) {
    setScrollState(SCROLL_STATE_DRAGGING);
}

最大的差别是 ViewPager 中对横纵坐标移动距离的比值做了判断,目的是确认滑动方向是横屏,可以避免 ViewPager 错误拦截纵向滑动的事件。

三、实验结论

  1. ViewPager2 是 final 类,对 ViewPager2 的滑动冲突处理可以在容器 Layout 中处理
  2. ViewPager2 嵌套 ViewPager2 的时候,滑动冲突的表现是内层 ViewPager2 不能滑动,原因是两个 ViewPager2 的 onInterceptTouchEvent 条件相同,外层先收到事件所以先启动拦截,导致触摸事件传不到内层 ViewPager2
  3. 解决滑动冲突的方式是自定义内层 ViewPager2 的父 View,在 dispatchTouchEvent 或者 onInterceptTouchEvent 中判断滑动,及时调用 requestDisallowInterceptTouchEvent 禁止外层 ViewPager2 拦截

四、后记

Android 的事件(传递)机制思考起来难度较高,即使已经理解了全过程,也可能在过程中跑偏,得出错误结论。思考的时候要明确 View 在事件机制中的排序,然后分为「从下往上」的事件分发过程和「从上往下」的事件消耗过程,找到正确的位置。

思考过程中看到 NestedScroll 机制差点被带歪了,虽然 NestedScroll 机制也是为了解决同向的滑动冲突,但使用 NestedScroll 的时候,我们的目标基本上是「将一次滑动事件分发给多个 View 处理」,ViewPager 的需求并不需要这么复杂。

基于 RecyclerView 实现 ViewPager2 是一种 RecyclerView 的灵活使用,看了部分源码,学习 RecyclerView 的热情逐渐高涨了起来,趁着放假,冲!


祝兄弟们春节快乐,牛年nb!