摘要|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 收不到事件。
具体的做法是:
- 判断触摸事件是否是滑动以及滑动的方向
- 判断子View是否能向该方向滑动
- 子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 错误拦截纵向滑动的事件。
三、实验结论
- ViewPager2 是 final 类,对 ViewPager2 的滑动冲突处理可以在容器 Layout 中处理
- ViewPager2 嵌套 ViewPager2 的时候,滑动冲突的表现是内层 ViewPager2 不能滑动,原因是两个 ViewPager2 的 onInterceptTouchEvent 条件相同,外层先收到事件所以先启动拦截,导致触摸事件传不到内层 ViewPager2
- 解决滑动冲突的方式是自定义内层 ViewPager2 的父 View,在 dispatchTouchEvent 或者 onInterceptTouchEvent 中判断滑动,及时调用 requestDisallowInterceptTouchEvent 禁止外层 ViewPager2 拦截
四、后记
Android 的事件(传递)机制思考起来难度较高,即使已经理解了全过程,也可能在过程中跑偏,得出错误结论。思考的时候要明确 View 在事件机制中的排序,然后分为「从下往上」的事件分发过程和「从上往下」的事件消耗过程,找到正确的位置。
思考过程中看到 NestedScroll 机制差点被带歪了,虽然 NestedScroll 机制也是为了解决同向的滑动冲突,但使用 NestedScroll 的时候,我们的目标基本上是「将一次滑动事件分发给多个 View 处理」,ViewPager 的需求并不需要这么复杂。
基于 RecyclerView 实现 ViewPager2 是一种 RecyclerView 的灵活使用,看了部分源码,学习 RecyclerView 的热情逐渐高涨了起来,趁着放假,冲!
祝兄弟们春节快乐,牛年nb!