事件分发机制:触摸事件分发流程、onTouchEvent、onInterceptTouchEvent、滑动冲突解决

触摸事件分发,说白了就是手指点到屏幕上之后,Android 系统怎么决定「谁来处理这个事件」。我刚开始做 Android 开发时,总觉得这是个玄学——明明点了按钮,却触发了背景的点击事件。后来踩了几次坑,才真正搞明白这套机制。

嗯,今天我们就把它彻底讲清楚。

事件分发的基本流程

一个触摸事件从产生到被消费,会经过三个核心方法:

  • dispatchTouchEvent:事件分发的入口,决定把事件交给谁
  • onInterceptTouchEvent:ViewGroup 专属,决定是否拦截事件
  • onTouchEvent:真正处理事件的地方

它们的关系可以用一句话概括:事件从外层向内层传递,处理结果从内层向外层返回

核心口诀:

Activity → ViewGroup → View(向下传递)

View → ViewGroup → Activity(向上回传)

为什么会这样?因为 Android 的设计哲学是:让最内层的 View 优先处理事件。如果它不处理,再往外层抛。

触摸事件分发流程图 Activity ViewGroup View dispatchTouchEvent onInterceptTouchEvent onTouchEvent 返回 true 1. Activity 收到事件 2. 传给 ViewGroup 3. ViewGroup 决定是否拦截 4. 不拦截则传给子 View 5. 子 View 处理或回传 6. 都不处理则 Activity 兜底

onInterceptTouchEvent:拦截还是不拦截?

这个方法只有 ViewGroup 才有。它返回 true 表示「这个事件我截胡了」,返回 false 表示「继续往下传」。

我在项目中遇到过一个问题:一个 ScrollView 里面嵌套了一个 ViewPager,结果滑动冲突了。手指左右滑的时候,ScrollView 总想抢着上下滑。这就是典型的拦截时机没控制好。

我的习惯:在自定义 ViewGroup 中,我一般会根据手指滑动的方向来判断是否拦截。比如水平滑动超过 10px 就放行给子 View,垂直滑动则自己拦截。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 记录按下位置
            lastX = ev.getX();
            lastY = ev.getY();
            // 不拦截 DOWN 事件,否则子 View 收不到任何事件
            return false;
        case MotionEvent.ACTION_MOVE:
            float deltaX = ev.getX() - lastX;
            float deltaY = ev.getY() - lastY;
            // 水平滑动超过阈值,不拦截
            if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
                return false;
            }
            // 垂直滑动,拦截
            return true;
    }
    return super.onInterceptTouchEvent(ev);
}

注意:千万不要在 DOWN 事件里返回 true 拦截。因为一旦拦截了 DOWN,后续的 MOVE、UP 事件都会直接交给当前 ViewGroup,子 View 永远收不到事件。我曾经因为这个 bug 排查了一整个下午……

onTouchEvent:事件最终的处理者

如果事件传到了某个 View 的 onTouchEvent,那就意味着「这个事件归你管了」。你返回 true 表示消费了事件,返回 false 表示你不处理,事件会往回传。

你想想看,一个按钮的点击效果是怎么实现的?其实就是 onTouchEvent 里检测到 ACTION_UP 时,执行点击回调。

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 按下时改变背景色
            setBackgroundColor(Color.LTGRAY);
            return true; // 消费事件
        case MotionEvent.ACTION_MOVE:
            // 手指移动时,判断是否移出了 View 区域
            if (!isInsideView(event)) {
                setBackgroundColor(Color.TRANSPARENT);
                return false; // 移出区域,不消费了
            }
            return true;
        case MotionEvent.ACTION_UP:
            // 抬起时恢复背景,执行点击
            setBackgroundColor(Color.TRANSPARENT);
            performClick();
            return true;
    }
    return super.onTouchEvent(event);
}

关键点:如果 onTouchEvent 返回 false,那么同一个事件序列(从 DOWN 到 UP)的所有后续事件都不会再传给这个 View。也就是说,你一旦说「我不处理」,后面就没你的事了。

滑动冲突的三种经典场景

滑动冲突是事件分发里最让人头疼的问题。我总结了三种常见场景:

场景 描述 解决思路
同方向冲突 内外两层都可以在同一个方向上滑动(如 ScrollView 嵌套 ListView) 外部拦截法:父容器根据需求决定是否拦截
不同方向冲突 内外两层滑动方向不同(如 ScrollView 嵌套 ViewPager) 根据滑动角度或距离判断方向,交给对应控件
嵌套滑动 内外两层需要协同滑动(如 CollapsingToolbarLayout) 使用 NestedScrolling 机制,让内外层配合

外部拦截法 vs 内部拦截法

解决滑动冲突,说白了就两种套路:

外部拦截法:父容器在 onInterceptTouchEvent 里做判断。如果判断结果是「这次滑动应该由子 View 处理」,就返回 false 不拦截;否则拦截。

内部拦截法:子 View 通过 requestDisallowInterceptTouchEvent(true) 告诉父容器「你别抢我的事件」。父容器收到这个信号后,就不再拦截了。

我个人习惯用外部拦截法,因为逻辑集中,好维护。但有些场景下,子 View 更清楚自己要不要处理事件,这时候内部拦截法更合适。

避坑指南:我曾经在一个项目中用了内部拦截法,但忘记在 ACTION_UP 时调用 requestDisallowInterceptTouchEvent(false),导致下一次触摸时父容器依然不拦截,整个页面滑动都失灵了。嗯,这个坑我记了很久。

实战:解决 ScrollView 嵌套 ViewPager 的滑动冲突

这是一个非常经典的场景。ViewPager 需要水平滑动,ScrollView 需要垂直滑动。两者互不干扰才是理想状态。

我的做法是自定义一个 MyScrollView,重写 onInterceptTouchEvent

public class MyScrollView extends ScrollView {
    private float lastX, lastY;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = ev.getX();
                lastY = ev.getY();
                return super.onInterceptTouchEvent(ev);
            case MotionEvent.ACTION_MOVE:
                float deltaX = Math.abs(ev.getX() - lastX);
                float deltaY = Math.abs(ev.getY() - lastY);
                // 水平滑动大于垂直滑动,不拦截
                if (deltaX > deltaY) {
                    return false;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
}

这样,当用户水平滑动 ViewPager 时,ScrollView 不会拦截事件;垂直滑动时,ScrollView 正常拦截并滚动。

核心思想:事件分发的本质是「责任链模式」。每个节点都有机会处理事件,也可以选择把事件交给下一个节点。理解了这个模式,滑动冲突就不再是玄学了。

好了,事件分发机制就讲到这里。记住那三个方法、两种拦截策略、一个核心流程图,大部分场景你都能应对。如果遇到奇怪的滑动冲突,不妨从「谁该处理这个事件」的角度去思考,往往能找到答案。


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