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 有发言权。

内部拦截法需要两个步骤:

  1. 父 View 默认不拦截任何事件(onInterceptTouchEvent 返回 false)
  2. 子 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 的辅助类,帮你处理嵌套逻辑

流程大概是这样的:

  1. 子 View 开始滑动前,调用 startNestedScroll() 通知父 View
  2. 子 View 滑动过程中,调用 dispatchNestedPreScroll() 问父 View:「你要不要先滑?」
  3. 父 View 滑完后,返回「我滑了多少」,子 View 再滑剩下的
  4. 子 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 知识体系总览

说了这么多,我画了一张图帮你理清思路。这张图展示了滑动冲突的三种解决方案及其关系:

滑动冲突解决方案知识体系 滑动冲突 外部拦截法 内部拦截法 NestedScrolling 核心机制 父 View 重写 onInterceptTouchEvent 判断是否拦截事件 DOWN 事件不能拦截 核心机制 子 View 调用 requestDisallow InterceptTouchEvent() 父 View 默认不拦截 核心机制 父子 View 协商滑动 startNestedScroll / dispatch NestedPreScroll 选择建议 • 父 View 自定义 → 外部拦截法 • 父 View 系统控件 → 内部拦截法 • 需要协同滑动 → NestedScrolling

这张图把三种方案的定位和核心机制都标出来了。你可以把它当作一个「决策树」——遇到滑动冲突时,先判断场景,再选方案。

6.8 小结

好了,这一章的内容就到这。我们讲了三种解决滑动冲突的方法:外部拦截法、内部拦截法、NestedScrolling 机制。前两种解决的是「谁抢到事件」的问题,NestedScrolling 解决的是「怎么一起滑」的问题。

我个人觉得,NestedScrolling 才是现代 Android 开发的主流方案。你看 CoordinatorLayout、AppBarLayout、SwipeRefreshLayout,底层全是 NestedScrolling。如果你还在用老方法拦截事件,不妨试试这套协商机制——体验真的不一样。

最后提醒一句:不管用哪种方案,一定要理解事件分发的底层流程。否则你写出来的拦截逻辑,很可能在某些边界场景下出 bug。我见过太多人把 onInterceptTouchEventonTouchEvent 搞混,结果滑动时各种诡异问题。


公众号:蓝海资料掘金营,微信deep3321