08.01 触摸事件处理

2025/2/5 Android 开发基础

前面章节中我们已经可以搭建各种界面布局(06 布局组件)和控件(07 控件),但是一个应用不能只是"能看",还需要"能交互"。用户点击一个按钮、滑动一个列表、长按一个卡片,这些交互行为都需要通过事件处理机制来响应。

Android 的事件处理主要分为两类:

  • 基于监听的事件处理:为组件绑定对应的事件监听器,事件发生时由监听器回调处理,是日常开发中最常用的方式。
  • 基于回调的事件处理:重写组件的回调方法(如 onTouchEvent()、onKeyDown()),事件发生时由框架自动调用。

而要真正理解"事件是怎么从屏幕到达控件的",就需要掌握 Android 的事件分发机制——它是自定义 View、手势识别、滑动冲突处理的基石,也是面试的高频考点。本节会从触摸事件的产生、传递、分发、消费的完整链路,配合图解把原理讲清楚。

# 一、触摸事件从哪里来:事件传递链路

先站在更高的视角看一次触摸的完整旅程。用户手指触碰屏幕时,硬件产生中断,驱动将原始数据上报给 Linux 内核,再由 Android 系统服务层层加工,最终送到我们的应用窗口:

触摸屏硬件
    ↓  中断/驱动上报
Linux 内核(Input Driver)
    ↓  读取原始输入
InputManagerService(IMS,系统服务)
    ↓  按 Window 分发
WindowInputEventReceiver(应用进程)
    ↓  主线程处理
DecorView(窗口根容器)
    ↓
Activity → ViewGroup 层级 → 具体控件(dispatchTouchEvent 分发链)

我们平时写的 OnClickListener 等,只是这条长链路最末端的一小环。系统框架保证了"每个触摸事件一定会被分发到某个控件",而开发者要关心的就是分发到控件层级之后的规则——即下面重点讲解的事件分发机制。

# 二、MotionEvent:触摸事件的数据模型

Android 中所有的触摸交互信息都被封装在 MotionEvent 对象中。用户的一次点击、滑动、长按,本质都是一系列 MotionEvent 在控件层级中流转的过程。

# 事件动作类型

常见的触摸动作类型:

  • MotionEvent.ACTION_DOWN:手指按下屏幕,一次触摸序列的起点
  • MotionEvent.ACTION_MOVE:手指在屏幕上移动(滑动过程中按帧连续产生)
  • MotionEvent.ACTION_UP:手指抬起离开屏幕,一次触摸序列的终点
  • MotionEvent.ACTION_CANCEL:事件被上层拦截取消(如父容器抢占了事件),不再传递给当前控件
  • MotionEvent.ACTION_POINTER_DOWN:第二个及之后的手指按下(多点触控)
  • MotionEvent.ACTION_POINTER_UP:多指中某个手指抬起

# 事件序列(一个完整触摸过程)

一次完整交互产生的事件流是有序的,可以用下面的示意图理解:

一次完整点击/滑动的事件序列示意图

从图中可以看出:

  1. 单击:ACTION_DOWN → ACTION_UP,两次事件,中间没有移动
  2. 长按:ACTION_DOWN → (静止保持约 500ms)→ ACTION_UP,中间没有 MOVE
  3. 滑动:ACTION_DOWN → ACTION_MOVE(连续 N 次)→ ACTION_UP

"事件序列"是理解分发机制的关键概念:从 ACTION_DOWN 开始到 ACTION_UP 结束的一系列事件构成一个序列,序列内所有事件的分发路径是一致的——即如果某个 View 消费了 DOWN,后续的 MOVE、UP 都会发给它;反之如果 DOWN 都没人消费,后续事件不会再进入 View 层级。

# 常用方法

  • getX() / getY():获取事件在当前 View 内部坐标系中的坐标(相对当前 View 左上角)
  • getRawX() / getRawY():获取事件在屏幕坐标系中的坐标(相对屏幕左上角)
  • getAction() / getActionMasked():获取事件动作类型(后者会剥离 pointer index 信息,处理多点触控时常用)
  • getPointerCount():获取当前屏幕上手指的数量(多点触控)
  • getX(int pointerIndex):获取指定手指的坐标(多点触控)
  • getEventTime() / getDownTime():事件时间戳 / DOWN 发生的时间戳

getX() 和 getRawX() 的区别,在需要做"控件跟手移动"时特别重要:前者用于计算控件自身的移动量,后者用于计算相对屏幕的位置。

# 三、基于监听的事件处理

监听器模型是 Android 事件处理的主要方式,核心三要素:事件源(控件)、事件(如点击、触摸)、事件监听器(EventListener)。

常用的监听器接口:

  • OnClickListener:点击事件,对应 onClick(View v)
  • OnLongClickListener:长按事件,对应 onLongClick(View v),返回 true 表示消费该事件
  • OnTouchListener:触摸事件,对应 onTouch(View v, MotionEvent event),返回 true 表示消费该事件
  • OnFocusChangeListener:焦点变化事件
  • OnKeyListener:按键事件(物理按键)
  • OnItemClickListener:列表项点击事件(AdapterView 系列,见 07.14 控件-AdapterView)

监听器的三种绑定方式:

  1. 匿名内部类方式(最常用)
Button button = findViewById(R.id.btn);
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Toast.makeText(getApplicationContext(), "点击了按钮", Toast.LENGTH_SHORT).show();
    }
});
  1. Activity 实现接口方式
public class DemoActivity extends AppCompatActivity implements View.OnClickListener {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);
        Button button = findViewById(R.id.btn);
        button.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.btn:
                Toast.makeText(getApplicationContext(), "点击了按钮", Toast.LENGTH_SHORT).show();
                break;
            default:
                break;
        }
    }
}
  1. xml 布局绑定方式(在布局中指定 android:onClick="onClick",要求方法签名为 public void xxx(View v))
<Button
    android:id="@+id/btn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:onClick="onClick"
    android:text="点击" />
public void onClick(View view) {
    Toast.makeText(getApplicationContext(), "点击了按钮", Toast.LENGTH_SHORT).show();
}

# onTouch 与 onClick 的执行顺序(重点)

一个控件同时设置 OnTouchListener 和 OnClickListener 时,执行顺序和返回值的影响可以用下面的判定链图理解:

onTouch 与 onTouchEvent 的判定链示意图

结论:

  1. onTouch 先执行,onClick 后执行
  2. onTouch 返回 true:事件在此被消费,不会进入 onTouchEvent,所以 onClick/onLongClick 都不会触发
  3. onTouch 返回 false:事件继续进入 onTouchEvent,由内部逻辑判定出点击/长按,onClick 正常触发
Button button = findViewById(R.id.btn);
button.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                Log.i(TAG, "onTouch: ACTION_DOWN");
                break;
            case MotionEvent.ACTION_MOVE:
                Log.i(TAG, "onTouch: ACTION_MOVE");
                break;
            case MotionEvent.ACTION_UP:
                Log.i(TAG, "onTouch: ACTION_UP");
                break;
            default:
                break;
        }
        // 返回false,事件继续传递,onClick依然会触发;返回true则onClick不再触发
        return false;
    }
});
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Log.i(TAG, "onClick: 按钮被点击");
    }
});

原理本质:onClick/onLongClick 并不是独立的事件源,而是在 View.onTouchEvent() 内部,根据 DOWN 与 UP 的间隔时长(长按判定)和位移大小(移动超出触摸阈值为滑动)"推导"出来的结果事件;而 onTouch 是拿到原始 MotionEvent 的过程事件。过程事件优先于结果事件,且可以拦截后者的产生——这就是顺序和返回值影响的根源。

# 四、基于回调的事件处理

除了监听器,View 和 Activity 还提供了一系列回调方法,重写它们同样可以处理事件:

  • onTouchEvent(MotionEvent event):View 的触摸事件回调
  • onKeyDown(int keyCode, KeyEvent event):按键按下
  • onKeyUp(int keyCode, KeyEvent event):按键抬起
  • onBackPressed():返回键按下(新版推荐使用 OnBackPressedDispatcher)
public class DemoActivity extends AppCompatActivity {
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 没有被任何控件消费的触摸事件,最终会回调到Activity这里
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                Log.i(TAG, String.format("点击屏幕空白位置:(%f, %f)", event.getX(), event.getY()));
                break;
            default:
                break;
        }
        return super.onTouchEvent(event);
    }
}

回调与监听并不冲突:同一个触摸事件在单个 View 内部的顺序是 onTouch(监听器)→ onTouchEvent(回调)→ onClick(结果事件),且前一步的消费状态决定后一步是否执行。

# 五、事件分发机制(原理核心)

这是本节最重要的部分。当一个触摸事件发生时,它并不是直接给到被点击的控件,而是要经历一个自上而下的分发和自下而上的回溯过程。

# 三大角色与核心方法

事件分发三大角色示意图

角色 核心方法 作用
Activity dispatchTouchEvent() 事件分发入口,交给窗口根 ViewGroup(DecorView)
ViewGroup dispatchTouchEvent() / onInterceptTouchEvent() / onTouchEvent() 决定拦截自己处理,还是分发给子元素
View dispatchTouchEvent() / onTouchEvent() 处理最终的触摸事件

三个方法各自的职责:

  • dispatchTouchEvent(MotionEvent ev):事件分发总入口,Activity、ViewGroup、View 都有。返回 true 表示事件已被消费。
  • onInterceptTouchEvent(MotionEvent ev):ViewGroup 独有。返回 true 表示拦截,事件交给自己的 onTouchEvent;返回 false 表示不拦截,继续分发给子元素。
  • onTouchEvent(MotionEvent ev):真正处理事件的方法。返回 true 表示消费该事件。

# 分发的完整流程:"U 型"传递

分发 + 回溯的完整过程可以用"U 型"图来理解——蓝色向下的分发是"问",橙色向上的回溯是"答":

事件分发U型传递示意图

结合上图,一次 ACTION_DOWN 的完整旅程:

  1. Activity 的 dispatchTouchEvent 把事件交给根 ViewGroup(DecorView)
  2. ViewGroup 的 dispatchTouchEvent 内部先问自己 onInterceptTouchEvent():要拦截吗?
    • 要拦截 → 事件进入 ViewGroup 自己的 onTouchEvent(),不再下传
    • 不拦截 → 在子元素中逆序查找"坐标命中的子元素",把事件转交给子元素的 dispatchTouchEvent
  3. 子元素重复第 2 步(如果还是 ViewGroup),直到到达叶子 View
  4. View 的 dispatchTouchEvent 先回调 onTouch 监听器(未消费时),再调用 onTouchEvent()
    • onTouchEvent 返回 true → 消费,回溯终止,U 型在底部折返点结束
    • 返回 false → 事件向上回溯给父容器的 onTouchEvent,父容器也返回 false 就继续向上,最后到 Activity 的 onTouchEvent

为什么子元素查找要"逆序"(从最后一个开始):因为 Android 中后添加/绘制在上层的元素更可能是用户想点的目标,逆序遍历保证最上层的控件优先收到事件。

# 分发机制的源码级伪代码

把 ViewGroup.dispatchTouchEvent 的核心逻辑抽象成伪代码,规则一目了然(源码很长,这里只保留主干):

// ViewGroup.dispatchTouchEvent 伪代码(帮助理解,非真实源码)
public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean handled = false;

    // 1. DOWN 事件:清空上一次事件序列的状态,重置触摸目标
    if (ev.getAction() == ACTION_DOWN) {
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }

    // 2. 判断是否拦截(三个条件都满足才判断):
    //    a. 事件是DOWN or 已经有触摸目标(child接受的DOWN)
    //    b. 不处于禁止拦截状态(requestDisallowInterceptTouchEvent)
    //    c. 是否是DOWN 或 已有目标
    boolean intercepted;
    if (disallowIntercept) {
        intercepted = false;  // 子View要求不要拦截
    } else {
        intercepted = onInterceptTouchEvent(ev);  // 询问子类是否要拦截
    }

    // 3. 未拦截 且 是DOWN:逆序遍历子元素,找命中坐标的子View分发
    if (!intercepted && ev.getAction() == ACTION_DOWN) {
        for (int i = childCount - 1; i >= 0; i--) {     // 逆序:最上层优先
            View child = getChildAt(i);
            if (区域命中(ev, child)) {
                // 转交子元素的dispatchTouchEvent,返回true说明子元素消费了
                if (child.dispatchTouchEvent(ev)) {
                    mFirstTouchTarget = child;           // 记录触摸目标
                    handled = true;
                    break;                               // 已有目标,结束查找
                }
            }
        }
    }

    // 4. 没有任何子元素消费(无触摸目标):自己处理
    if (mFirstTouchTarget == null) {
        handled = dispatchTransformedTouchEvent(ev, ..., this /* View的onTouchEvent */);
    }

    // 5. 后续MOVE/UP事件:DOWN时定下的目标直接接收(不再重新查找)
    //    若父容器中途拦截:给子元素发ACTION_CANCEL,事件转给自己处理
    return handled;
}
// View.dispatchTouchEvent 伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
    // 1. 先给 onTouch 监听器机会
    if (mOnTouchListener != null && mOnTouchListener.onTouch(this, event)) {
        return true;                          // onTouch返回true:消费,结束
    }
    // 2. 否则走 onTouchEvent
    return onTouchEvent(event);
}

// View.onTouchEvent 内部核心逻辑
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case ACTION_DOWN:
            // 记录按下时间、坐标,若为可点击控件则postDelayed检查长按(默认500ms)
            break;
        case ACTION_MOVE:
            // 移动超出触摸阈值(scaledTouchSlop):取消长按检查,标记为"拖动"
            break;
        case ACTION_UP:
            // 在阈值内抬起:执行 performClick() → 触发 onClick
            // 按住超过500ms:此前已触发 onLongClick,UP不再算单击
            break;
    }
    // clickable/touchable 为 true 的控件(如Button)返回true,普通View(如TextView未设点击)返回false
    return clickable;
}

# 四条关键规则总结

  1. 事件序列锚定:某个 View 消费了 ACTION_DOWN,同一序列的后续 MOVE、UP 都会直接交给它处理(通过 DOWN 时记录的 touch target),不再每次重新分发查找;如果没有任何 View 消费 DOWN,后续事件不会进入 View 层级,直接交给 Activity 的 onTouchEvent。
  2. 拦截一旦发生就持续:ViewGroup 一旦在序列中途决定拦截,子元素会收到一个 ACTION_CANCEL("你的事件被收走了"),后续所有事件都交给父容器自己处理。
  3. requestDisallowInterceptTouchEvent(true):子 View 调用父容器的该方法,可以禁止父容器在本次事件序列中拦截(对应伪代码中的 disallowIntercept),这是"内部拦截法"的基础。
  4. 消费判定:onTouch 与 onTouchEvent 只要有一个返回 true,该 View 就算消费了事件;控件的 clickable/longClickable 属性会影响 onTouchEvent 的默认返回值(Button 默认可点击,TextView 默认不可点击)。

# 简单验证示例

自定义一个 ViewGroup 和 View,重写分发相关方法并打日志,即可直观看到上面 U 型图的流转过程:

/**
* 自定义 ViewGroup,观察事件分发过程.
*/
public class MyViewGroup extends LinearLayout {
    private static final String TAG = "MyViewGroup";

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i(TAG, "dispatchTouchEvent: " + ev.getAction());
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.i(TAG, "onInterceptTouchEvent: " + ev.getAction());
        // 返回false不拦截,事件继续分发给子View
        return false;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i(TAG, "onTouchEvent: " + event.getAction());
        // 返回true表示消费事件
        return true;
    }
}
/**
* 自定义 View,观察事件处理过程.
*/
public class MyView extends View {
    private static final String TAG = "MyView";

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i(TAG, "dispatchTouchEvent: " + ev.getAction());
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i(TAG, "onTouchEvent: " + event.getAction());
        return true;
    }
}

四种典型场景的日志表现(可自行实验验证):

场景 子View.onTouchEvent 日志表现
子 View 消费 DOWN 返回 true 子 View 收到完整 DOWN/MOVE/UP 序列
子 View 不消费 DOWN 返回 false DOWN 一路回溯到 ViewGroup/Activity 的 onTouchEvent,后续事件不再经过子 View
父容器拦截 任意 子 View 收到 ACTION_CANCEL,后续事件全走父容器的 onTouchEvent
子 View onTouch 返回 true — 子 View 消费序列,onTouchEvent 不会被调用

# ACTION_CANCEL:被收走的事件

ACTION_CANCEL 容易被忽略但很重要:当子 View 正在处理事件序列(比如列表项高亮中),父容器中途拦截(或父容器滑动离开、窗口移除等),子 View 会收到 ACTION_CANCEL,表示"本次触摸序列与你无关了"。处理触摸逻辑的控件(如按压效果、跟手拖动)必须处理 CANCEL,把状态复原,否则会出现"高亮卡住"之类的 UI 异常:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_CANCEL:
            // 事件被父容器收走,恢复按下前的状态(如清除按压高亮)
            setPressed(false);
            return true;
        case MotionEvent.ACTION_UP:
            setPressed(false);
            performClick();
            return true;
        default:
            return true;
    }
}

# 六、多点触控

一个屏幕可以同时有多个手指触摸,MotionEvent 用 pointer(指针) 来管理每个手指。涉及的概念:

  • getPointerCount():当前触点数量
  • getPointerId(int index) / findPointerIndex(int id):index(数组下标,会变)与 id(手指身份,不变)互相转换
  • getX(int pointerIndex):获取指定手指的坐标
  • ACTION_POINTER_DOWN / ACTION_POINTER_UP:非第一个手指按下/抬起
@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            // 第一个手指按下
            Log.i(TAG, "第一根手指按下,位置:(" + event.getX() + ", " + event.getY() + ")");
            break;
        case MotionEvent.ACTION_POINTER_DOWN:
            // 非第一个手指按下
            int pointerIndex = event.getActionIndex();
            int pointerId = event.getPointerId(pointerIndex);
            Log.i(TAG, "新手指按下 id = " + pointerId
                    + ",当前共 " + event.getPointerCount() + " 个触点");
            break;
        case MotionEvent.ACTION_MOVE:
            // 遍历所有触点的坐标(双指缩放手势的基础)
            for (int i = 0; i < event.getPointerCount(); i++) {
                Log.i(TAG, "pointer" + i + ": (" + event.getX(i) + ", " + event.getY(i) + ")");
            }
            break;
        case MotionEvent.ACTION_POINTER_UP:
            // 某个非首发手指抬起
            Log.i(TAG, "手指抬起 id = " + event.getPointerId(event.getActionIndex()));
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            // 最后一个手指抬起 / 事件被收走
            break;
        default:
            break;
    }
    return true;
}

注意区分:处理多点触控时必须用 getActionMasked() 而不是 getAction()——后者会混入 pointer index 位,switch 匹配会失效;缩放、双指旋转等手势都是基于多点触控的坐标变化计算出来的。

# 七、GestureDetector 手势检测

OnClickListener 只能感知"点击",OnTouchListener 只能拿到原始 MotionEvent。要识别滑动、双击、甩动(Fling)、长按等复合手势,Android 提供了 GestureDetector 帮我们做识别——它本质上是把上一节讲的"DOWN/UP 时长、MOVE 位移、速度"等原始判断逻辑封装成了语义化的回调。

使用步骤:

  1. 创建 GestureDetector 并传入 OnGestureListener(推荐用其子类 SimpleOnGestureListener,只重写关心的手势)
  2. 在控件的 onTouch 中把事件转交给 GestureDetector.onTouchEvent()
public class DemoActivity extends AppCompatActivity {
    private static final String TAG = "DemoActivity";
    private GestureDetector mGestureDetector;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);

        // 使用SimpleOnGestureListener,只需重写关心的手势
        mGestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
            // 按下(所有手势的起点)
            @Override
            public boolean onDown(MotionEvent e) {
                Log.i(TAG, "onDown: 手指按下");
                return true;
            }

            // 单击确认(排除了双击的第一次点击)
            @Override
            public boolean onSingleTapConfirmed(MotionEvent e) {
                Log.i(TAG, "onSingleTapConfirmed: 单击");
                return true;
            }

            // 双击
            @Override
            public boolean onDoubleTap(MotionEvent e) {
                Log.i(TAG, "onDoubleTap: 双击");
                return true;
            }

            // 长按(默认500ms)
            @Override
            public void onLongPress(MotionEvent e) {
                Log.i(TAG, "onLongPress: 长按");
            }

            // 拖动(按住滑动,distance为相对上一次回调的位移)
            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2,
                                    float distanceX, float distanceY) {
                Log.i(TAG, String.format("onScroll: dx = %f, dy = %f", distanceX, distanceY));
                return true;
            }

            // 甩动(快速滑动后抬起,带初速度,适合惯性滑动/翻页)
            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2,
                                   float velocityX, float velocityY) {
                Log.i(TAG, String.format("onFling: vx = %f, vy = %f", velocityX, velocityY));
                return true;
            }
        });

        View view = findViewById(R.id.touch_view);
        view.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                // 将事件交给GestureDetector解析
                return mGestureDetector.onTouchEvent(event);
            }
        });
    }
}

手势回调一览:

回调 触发时机 典型用途
onDown 手指按下 手势开始初始化
onSingleTapConfirmed 确认单击 单击响应(可排除双击)
onDoubleTap / onDoubleTapEvent 双击 / 双击后续事件 双击缩放(如图片查看器)
onLongPress 按住 ≥ 500ms 长按菜单
onScroll 按住拖动 跟手移动
onFling 快速滑动后抬起 惯性滚动、翻页
onShowPress 按下但尚未长按/抬起 按压反馈

# 八、坐标系统

事件处理绕不开坐标计算,Android 有三套常用坐标系,务必分清:

API 坐标原点 含义
MotionEvent.getX()/getY() 当前 View 左上角 触点相对当前 View 的位置
MotionEvent.getRawX()/getRawY() 屏幕左上角 触点相对屏幕的位置
View.getLeft()/getTop() 父容器左上角 View 自身在父容器中的位置
View.getX()/getY() 父容器左上角 View 的位置 + 平移量(translationX/Y),用于动画
View.getLocationOnScreen(int[]) 屏幕左上角 View 在屏幕上的绝对位置
// 让控件跟随手指移动的经典写法(结合getX与getRawX)
view.setOnTouchListener(new View.OnTouchListener() {
    private float mLastRawX;
    private float mLastRawY;

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 记录按下时手指的屏幕坐标
                mLastRawX = event.getRawX();
                mLastRawY = event.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                // 计算手指移动的增量
                float dx = event.getRawX() - mLastRawX;
                float dy = event.getRawY() - mLastRawY;
                // 平移控件(getX/getY包含translation,可直接用setTranslationX/Y)
                v.setTranslationX(v.getTranslationX() + dx);
                v.setTranslationY(v.getTranslationY() + dy);
                mLastRawX = event.getRawX();
                mLastRawY = event.getRawY();
                break;
            default:
                break;
        }
        return true;
    }
});

# 九、常见滑动冲突及解决

当内外两层视图同时可以滑动时,同一个触摸事件在两层之间的分发会互相干扰,这就是典型的滑动冲突。三类场景如下:

三类典型滑动冲突场景示意图

  1. 外部滑动与内部滑动方向不一致:如左右滑的容器嵌上下滑的列表——靠"滑动方向判断"解决
  2. 外部滑动与内部滑动方向一致:如 ScrollView 嵌套 ScrollView——方向判断失效,需按业务规则(如内容是否已滑到边界)决定归属
  3. 以上两种的嵌套组合:如 ScrollView 嵌 ViewPager 嵌 RecyclerView——逐层拆解处理

解决的经典方法是两种"拦截法":

# 外部拦截法(推荐)

事件先经过父容器,父容器在 onInterceptTouchEvent 中判断:"该我自己滑动的事件我就拦截,否则交给子 View"。对应 U 型图里 ViewGroup 的拦截判断点。

/**
* 外部拦截法示例:横向滑动的容器嵌套纵向滑动的列表(场景①).
*/
public class HorizontalContainerView extends LinearLayout {
    private static final String TAG = "HorizontalContainerView";
    // 记录最后一次分发事件的坐标
    private float mLastX;
    private float mLastY;
    // 系统定义的最小滑动距离阈值(多少像素的位移才算"滑动"而不是"点击")
    private int mTouchSlop;

    public HorizontalContainerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // DOWN事件不能拦截:拦截了DOWN,子View收不到完整事件序列,
                // 后续MOVE/UP也不会再来,子View的点击等逻辑全部失效
                intercepted = false;
                break;
            case MotionEvent.ACTION_MOVE: {
                float dx = x - mLastX;
                float dy = y - mLastY;
                // 横向位移大于纵向位移(且超过阈值),判定为横向滑动,父容器拦截
                if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > mTouchSlop) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            }
            case MotionEvent.ACTION_UP:
                // UP事件不拦截,保证子View能收到完整事件序列的收尾
                intercepted = false;
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return intercepted;
    }
}

外部拦截法的要点:

  • ACTION_DOWN 和 ACTION_UP 一般不拦截,判定逻辑集中在 ACTION_MOVE 中
  • 一旦在 MOVE 中拦截,本序列后续事件(包括 CANCEL 会发给子 View)都走父容器
  • 需要滑动距离阈值 TouchSlop,避免把轻微抖动误判为滑动

# 内部拦截法

父容器不主动拦截(onInterceptTouchEvent 固定策略),由子 View 通过 requestDisallowInterceptTouchEvent(true/false) 实时控制父容器"让不让抢"。对应伪代码中的 disallowIntercept 分支。

/**
* 内部拦截法示例:子View根据滑动方向决定是否禁止父容器拦截.
*/
public class InnerListView extends ListView {
    private static final String TAG = "InnerListView";
    private float mLastX;
    private float mLastY;

    public InnerListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // DOWN时先禁止父容器拦截,保证子View能拿到事件
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE: {
                float dx = x - mLastX;
                float dy = y - mLastY;
                // 纵向位移大于横向位移:是自己的滑动场景,继续禁止父容器拦截
                if (Math.abs(dy) > Math.abs(dx)) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                } else {
                    // 横向滑动交给外层容器处理,放开拦截限制
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // 事件序列结束,恢复默认
                getParent().requestDisallowInterceptTouchEvent(false);
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(ev);
    }
}

内部拦截法需要配合修改父容器的 onInterceptTouchEvent:ACTION_DOWN 时必须返回 false(否则事件进不了子 View),其他动作返回 true(实际由子 View 的标志位控制)。

# 两种方法对比

对比项 外部拦截法 内部拦截法
判定方 父容器 子容器
实现位置 onInterceptTouchEvent dispatchTouchEvent + requestDisallow
逻辑集中度 集中在父容器,清晰 分散在父子两端,需配合修改父容器
使用建议 优先使用 需要子 View 掌控主动权时使用

日常开发中,官方控件已经内置了很多协调逻辑(如 CoordinatorLayout、NestedScrolling 嵌套滚动机制、ViewPager2 的方向处理),优先复用;只有在自定义滑动容器时才需要手动处理分发逻辑。

# 十、常见问题与陷阱

最后梳理几个实战中高频出现的问题,都对应上面讲过的原理:

  1. onClick 没反应:多半是 onTouch 返回了 true 拦截了消费,或者父容器在 MOVE 中拦截导致子 View 收到 CANCEL 没走完 UP。
  2. 子 View 滑不动:父容器(如 ScrollView)把事件抢走了——用内部拦截法 requestDisallowInterceptTouchEvent 或外部拦截法放开。
  3. 列表项按压态卡住不变:没处理 ACTION_CANCEL,事件被收走后按压状态没复原。
  4. View 拿到的 ACTION_MOVE 坐标跳动:多点触控场景用了 getAction() 而非 getActionMasked(),或者没按 pointer index 取坐标。
  5. TextView 设置了 onTouchEvent 却返回 false:默认 clickable = false,onTouchEvent 返回 false 收不到完整序列;调用 setClickable(true) 或设置监听器即可。

# 结束

本节系统介绍了 Android 的事件处理机制:从触摸硬件到应用窗口的事件传递链路、MotionEvent 数据模型与事件序列、基于监听/回调的两种处理方式、事件分发机制的 U 型传递与源码级规则(dispatchTouchEvent / onInterceptTouchEvent / onTouchEvent 三者的协作)、ACTION_CANCEL 与多点触控、GestureDetector 手势识别、坐标系统,以及三类滑动冲突场景与外部/内部两种拦截方法。事件分发是自定义 View 和手势交互的基石,建议动手跑一遍本节的日志验证示例,再结合源码(ViewGroup.dispatchTouchEvent、View.onTouchEvent)加深理解。更多内容请参考:Input events (opens new window) 与 GestureDetector (opens new window)。