6、事件分发机制(下):滑动冲突的经典解决方案与NestedScrolling机制
好,咱们接着聊事件分发。上一章我们把事件分发的底层逻辑讲透了,从 Activity 到 ViewGroup 再到 View,整个链条都捋了一遍。但说实话,光知道原理还不够——你写代码时真正头疼的,往往是「滑动冲突」。
什么叫滑动冲突?说白了就是两个能滑动的控件嵌套在一起,手指一划,到底该谁响应?我早期做项目时就被这个问题坑过——一个 ViewPager 里嵌了个可滑动的列表,结果用户左右滑动时列表也跟着上下抖,体验极差。嗯,今天我们就来彻底解决这个问题。
6.1 滑动冲突的本质
先想一个问题:为什么会有滑动冲突?
因为 Android 的事件分发机制是「责任链模式」。事件从父 View 传到子 View,但父 View 和子 View 都可能想消费同一个滑动事件。比如一个水平滑动的 ViewPager 里嵌套了一个垂直滑动的 RecyclerView,手指斜着划一下,系统就懵了——该让谁动?
我个人的经验是,解决滑动冲突前,先要判断冲突的类型。常见的就三种:
- 同方向冲突:内外都能上下滑,比如 ScrollView 嵌套 ListView(老项目常见)
- 不同方向冲突:一个水平滑,一个垂直滑,比如 ViewPager 嵌套 RecyclerView
- 嵌套滑动:内外都想滑,但需要协同,比如 CoordinatorLayout 里的嵌套滚动
前两种用「外部拦截法」或「内部拦截法」就能搞定。第三种嘛,得请出 NestedScrolling 机制。
6.2 外部拦截法:父 View 说了算
外部拦截法的思路很简单:父 View 先拿到事件,判断要不要自己处理,不处理再丢给子 View。说白了就是「家长制」,父 View 说了算。
具体怎么做?重写父 View 的 onInterceptTouchEvent() 方法。这个方法返回 true 就拦截,返回 false 就放行。
我举个例子。假设你有一个水平滑动的父容器,里面放了一个垂直滑动的子列表。你想让手指水平滑动时父容器响应,垂直滑动时子列表响应。代码可以这样写:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
// 按下时不能拦截,否则子 View 收不到事件
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// 水平滑动距离大于垂直,就拦截
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastX = x;
mLastY = y;
return intercepted;
}
核心要点:ACTION_DOWN 事件绝对不能拦截。为什么?因为事件序列是从 DOWN 开始的,一旦父 View 在 DOWN 时拦截了,后续所有事件都直接交给父 View,子 View 永远没机会了。
外部拦截法的优点是逻辑清晰,父 View 掌控全局。缺点嘛,父 View 的拦截逻辑写死了,子 View 想「抢」事件就难了。
6.3 内部拦截法:子 View 说了算
内部拦截法正好反过来——子 View 先拿到事件,它决定要不要自己处理,不处理再「请求」父 View 别拦截。这有点像「民主制」,子 View 有发言权。
内部拦截法需要两个步骤:
- 父 View 默认不拦截任何事件(
onInterceptTouchEvent返回 false) - 子 View 通过
requestDisallowInterceptTouchEvent()方法告诉父 View:「你别抢我的事件」
代码实现上,子 View 要重写 dispatchTouchEvent():
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 按下时允许父 View 拦截
getParent().requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// 如果是垂直滑动,不让父 View 拦截
if (Math.abs(deltaY) > Math.abs(deltaX)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
避坑指南:我曾经在项目里用内部拦截法时踩过一个坑——子 View 的 ACTION_DOWN 事件里调用了 requestDisallowInterceptTouchEvent(true),结果父 View 连 DOWN 都收不到,整个事件序列全乱了。记住,DOWN 事件一定要让父 View 先处理,否则后续的拦截逻辑会失效。
内部拦截法的好处是子 View 更灵活,适合子 View 需要「抢」事件的场景。但缺点也很明显——你得保证父 View 的 onInterceptTouchEvent 确实不拦截 DOWN 事件,否则内部拦截法根本跑不起来。
6.4 两种方法的对比
| 对比维度 | 外部拦截法 | 内部拦截法 |
|---|---|---|
| 控制方 | 父 View | 子 View |
| 核心方法 | onInterceptTouchEvent | requestDisallowInterceptTouchEvent |
| 适用场景 | 父 View 主导,子 View 被动 | 子 View 主导,父 View 配合 |
| 代码复杂度 | 较低,只需改父 View | 较高,需要父子配合 |
| 灵活性 | 一般,拦截逻辑固定 | 较高,子 View 可动态控制 |
我个人习惯是:如果父 View 是自定义的,用外部拦截法;如果父 View 是系统控件(比如 ViewPager),就用内部拦截法。你想想看,系统控件你改不了源码,只能从子 View 下手。
6.5 NestedScrolling 机制:让嵌套滑动「协同」起来
前面两种方法解决的是「谁抢到事件谁说了算」的问题。但有些场景下,我们希望内外一起滑——比如下拉刷新时,头部先滑出一段,然后列表再开始滚动。这就是 NestedScrolling 机制要干的事。
NestedScrolling 说白了就是一套「协商」机制。父 View 和子 View 不再是竞争关系,而是合作关系。子 View 滑动时,会先问父 View:「你要不要先滑?」父 View 滑完了,子 View 再滑剩下的。
这套机制的核心接口有四个:
- NestedScrollingChild:子 View 实现,表示「我能嵌套滑动」
- NestedScrollingParent:父 View 实现,表示「我能配合嵌套滑动」
- NestedScrollingChildHelper:子 View 的辅助类,帮你处理嵌套逻辑
- NestedScrollingParentHelper:父 View 的辅助类,帮你处理嵌套逻辑
流程大概是这样的:
- 子 View 开始滑动前,调用
startNestedScroll()通知父 View - 子 View 滑动过程中,调用
dispatchNestedPreScroll()问父 View:「你要不要先滑?」 - 父 View 滑完后,返回「我滑了多少」,子 View 再滑剩下的
- 子 View 滑完后,调用
dispatchNestedScroll()告诉父 View:「我滑完了,剩下的你处理」
你可能会问:这跟 onInterceptTouchEvent 有什么区别?区别大了。NestedScrolling 是在事件分发 之后 的「二次协商」,它不改变事件分发的流向,而是在子 View 已经拿到事件后,再跟父 View 商量「怎么滑」。说白了,事件还是子 View 的,但滑动距离可以「分」给父 View。
举个例子:CoordinatorLayout 里的 AppBarLayout 和 RecyclerView 就是典型的 NestedScrolling 应用。你往上滑列表时,AppBarLayout 先收缩,等它缩完了,RecyclerView 才开始滚动。整个过程非常丝滑,靠的就是这套协商机制。
6.6 NestedScrolling 的实战要点
如果你要自己实现 NestedScrolling,记住几个关键点:
- 子 View 必须调用 startNestedScroll(),否则父 View 不会参与协商
- dispatchNestedPreScroll() 的返回值表示父 View 消费了多少滑动距离,子 View 要用「总距离 - 消费距离」来算自己该滑多少
- 惯性滑动(fling)也要协商,通过
dispatchNestedPreFling()和dispatchNestedFling()
我曾经在做一个自定义下拉刷新控件时,就用了 NestedScrolling 机制。子 View 是 RecyclerView,父 View 是刷新头。手指下滑时,刷新头先出来,等刷新头完全展开了,RecyclerView 才开始 overscroll。整个过程不需要拦截事件,全靠协商,体验非常好。
6.7 知识体系总览
说了这么多,我画了一张图帮你理清思路。这张图展示了滑动冲突的三种解决方案及其关系:
这张图把三种方案的定位和核心机制都标出来了。你可以把它当作一个「决策树」——遇到滑动冲突时,先判断场景,再选方案。
6.8 小结
好了,这一章的内容就到这。我们讲了三种解决滑动冲突的方法:外部拦截法、内部拦截法、NestedScrolling 机制。前两种解决的是「谁抢到事件」的问题,NestedScrolling 解决的是「怎么一起滑」的问题。
我个人觉得,NestedScrolling 才是现代 Android 开发的主流方案。你看 CoordinatorLayout、AppBarLayout、SwipeRefreshLayout,底层全是 NestedScrolling。如果你还在用老方法拦截事件,不妨试试这套协商机制——体验真的不一样。
最后提醒一句:不管用哪种方案,一定要理解事件分发的底层流程。否则你写出来的拦截逻辑,很可能在某些边界场景下出 bug。我见过太多人把 onInterceptTouchEvent 和 onTouchEvent 搞混,结果滑动时各种诡异问题。