文章

Android 事件分发机制与多点触控

Android 事件分发机制一直让人头痛,之前也是面向 GitHub 编程得过且过。今天下定决心了解一下,以便后面自己定制 View 效果。

事件类型

Android 触摸事件有两大类:ACTION_*ACTION_POINTER_*。前者代表「主要手指」的事件,后者是多点触控中其他手指的事件。对于 DOWN 事件,第一根按下的是主要手指;对于 UP 事件,最后一根抬起的是主要手指。

MotionEvent.action 实际是包含两种数据,一个是事件类型,一个是手指序号。所以需要使用 MotionEvent.actionMasked 把类型提取出来。而「主要手指」的序号恰好是 0,所以 MotionEvent.action 只能判断出 ACTION_* 事件,其值永远不会与 ACTION_POINTER_* 相等。简单起见,建议始终使用 actionMasked

触摸事件有三个基本类型:ACTION_DOWN, ACTION_MOVE, ACTION_UP,后两者的传递顺序取决于 DOWN 的传递结果,所以就从 ACTION_DOWN 开始分析。

相关方法

onTouchEvent()

处理事件的逻辑一般写在这里。默认返回 false,返回 true 表示消费事件,此事件不再向上回溯。只有消费了 DOWN 事件,才可以收到后续事件。 其他事件 onTouchEvent() 的返回值没有意义。

onInterceptEvent()

只有 ViewGroup 才有的方法,默认返回 true 继续分发。返回 false 则不再向下分发事件,转而调用自己的 onTouchEvent()一旦有一次返回 true,之后的事件不再经过这个方法,也不再向下分发,而是直接分派给自己的 onTouchEvent()

一般用于在子 View 已经消费事件的情况下抢回处理权。例如 RecyclerView 默认不消费 DOWN 事件,但若检测到滑动,就要把后续事件处理权从子 View 手里抢回来。

根据 onInterceptEvent() 的特性,我们可以得出以下结论:

  • 重写了这个方法的 ViewGroup 一定需要重写 onTouchEvent():如果不使用,那为什么要拦截呢?
  • 普通 View 没有这个方法是因为它们没有子 View。
  • 一般不要在 DOWN 事件时返回 true,这相当于完全剥夺子 View 处理事件的权利。因此 onTouchEvent() 可能并没有收到先序 DOWN 事件(被子 View 消费了),所以通常要在这个函数中记录先序事件的属性。又因为如果用户直接触摸了自己(触摸位置没有子 View)那么拦截事件不会触发,而只会触发自己的 onTouchEvent(),所以拦截器中的这部分记录代码与 onTouchEvent() 中部分代码有重复,且缺一不可

ViewGroup 还有个方法叫 requestDisallowInterceptTouchEvent() 可以禁止父 View 使用 onInterceptEvent() 拦截事件,常用于垂直滑动冲突。例如 ViewPager 里有一个 RecyclerView。用户垂直滑动的过程中可能因手指长度问题实际滑动轨迹是个扇形,在事件序列过程中(即 DOWN 与 UP/CANCEAL 之间)使用此方法就可以防止后半段行程被识别为 ViewPager 翻页。

dispatchTouchEvent()

一般无需重写这个方法。

总管函数,它负责(间接)调用上面两个函数或子 View 的 dispatchTouchEvent()。父 View 也是通过这个方法的返回值确定子 View 是否消费事件。这个方法有最高抢夺事件的权力,父级可以在任何时候抢夺子级的任何事件。

通常应该调用 super 的方法。若不调用 super 直接返回 true 表示消费事件但实际上没消费,相当于吞事件。直接返回 false 相当于不消费,事件回溯到上一级的 onTouchEvent()

OnTouchListener

准确说这不是个方法,而是类似 OnClickListener 的东西,可以在不自定义 View 的情况下修改触摸逻辑。它执行在 onTouchEvent 之前。若 OnTouchListener 返回 true,onTouchEvent() 就不再执行。

ACTION_DOWN

全景

借用一张下面参考文章里的全景图片,注意这里指的仅仅是 ACTION_DOWN 事件的传递。先解释一下:

  • 白色箭头表示事件传递(函数调用)
  • 箭头上的标注表示调用前提。(supper 表示上一级直接调用,false 表示若上级返回 false 则系统继续向下调用)
  • 白色方块内的消费箭头表示若此函数返回对应值,则事件终止传递(也称作被消费了)

以左上角事件入口为例,首先 Activity 收到事件触发 dispatchTouchEvent,不论返回 true 还是 false 事件均终止,任何组件的任何函数均不会再被调用(包括 activity 自己的 onTouchEvent),只有 return super.dispatchTouchEvent() 也就是调用了 super 才会继续传递到下一级。

对于下一级 ViewGroup 的 dispatchTouchEvent 来讲,返回 true 同样消费事件立即终止传递。返回 false 则会回溯到上一层的 onTouchEvent。调用 super 则继续向下传递。

全程传递

我们假设事件没有被拦截、消费,那么整个传输流程类似 U 型:

不难看出,整个流程分为左右两部分,我们暂且叫做分派回溯。分派是自顶到底的,主要用于事件的传递。回溯是从底到顶的,主要用于事件的处理。所有方法的默认实现就是 return super.xxx() 因此事件默认情况下可以走完整个流程。

总结

到此为止 ACTION_DOWN 的传递流程基本上分析完了,最后总结一下在不同的阶段要达到不同的目的应该执行什么操作。

分派阶段

期望行为 操作
继续分派 调用 super
消费事件,终止整个流程 dispatchTouchEvent() return true
终止分派,向上回溯 dispatchTouchEvent() return false
终止分派,交给自己处理 ViewGroup: onInterceptEvent return true

回溯阶段

期望行为 操作
继续回溯 调用 super 或 return false
消费事件,终止回溯 return true

ACTION_MOVE/UP

不同情况

DOWN 事件不同,其他的都属于后续事件,默认情况下只有消费了先序事件(例如 DOWN)的控件才能收到后续事件,它的上层控件负责分派后续事件(有条件强行拦截)。这么说太抽象了,画个图看看(下图中红色表示 DOWN 事件,蓝色表示后续事件)

也就是说,只有在 DOWN 事件中 onTouchEvent() 的返回值才有意义。

dispatchTouchEvent 吞事件

此时对于后续事件来讲,相当于 ViewGroup2 消费了上一个事件,而 Activity, ViewGroup1 都是它的上层控件,因此它们继续负责分派后续事件。

onTouchEvent 消费事件

和上一个情形类似,不再赘述了。

父级 onTouchEvent 消费事件

这次稍微有点不同,因为 ViewGroup1 消费了事件,因此只有它的上层需要负责后续事件的分派,也就是只有 Activity。

dispatchTouchEvent 不消费

这次我们在 View 的 dispatchTouchEvent 返回了 false,也就是会直接回溯到 ViewGroup2. 通过这个案例可以看出,后续事件的传递仅与消费先序事件的控件及其上层控件有关。

onInterceptEvent 拦截事件

再次印证了上一个情况得出的结论。

总结

总结后续事件的传递路径,就是一直传递到消费先序事件的控件,并传递到消费先序事件的方法截止。注意,onInterceptEvent 只能拦截事件不能消费事件。

多点触控

触摸事件中的多个数据

上面说的还只是一根手指的情况。有多个手指时,目前我们知道会触发 ACTION_POINTER_* 事件,但这不包含 ACTION_POINTER_MOVE,那...怎么判断哪一个手指移动了?

这里要澄清一个概念:触摸事件不是基于手指的,而是基于 View 的。比如收到了一个 ACTION_MOVE 事件不能说「有一个手指移动了」,而只能说「这个 View 上有手指移动了」。只字之差隐含了一个关键事实:一个事件中可能包含多个手指的数据!

比如下面这个事件序列,后面的 p 表示手指数据(包含坐标、手指索引、手指 id 等):

ACTION_DOWN         p0
ACTION_MOVE         p0
ACTION_MOVE         p0
ACTION_POINTER_DOWN p0,p1 // 此时第二个手指按下,那么后面的事件都包含两个手指的数据
ACTION_MOVE         p0,p1
ACTION_POINTER_UP   p0,p1 // 此时先按下的手指抬起了
ACTION_MOVE         p0    // 此时的 p0 其实是上面 p1 的手指,因为旧的 p0 抬起了,所以会更新索引
ACTION_UP           p0

从这例子还可以看出,index 是会改变的,不能用这个属性来识别物理上的手指,这个需求应该用 id 实现。

为什么 index 要设计成可变的呢?

因为 index 是用于遍历的。 总是从 0 开始便于 for 循环。

MotionEvent.getX() 等方法提供了带参数的版本,传入 index 就可获得不同手指的触摸数据。MotionEvent.findPointerIndex() 则可以通过 id 获取当前事件中的 index。

获取触发事件的手指

如何取得触发本次事件的手指的数据?这要分多钟情况讨论。

  • ACTION_DOWN / ACTION_UP: 直接 getX() 就行,因为目前只有一根手指。
  • ACTION_POINTER_DOWN / ACTION_POINTER_UP: MotionEvent.getActionIndex() 来获取触发事件的手指 id。
  • ACTION_MOVE: 无法获取。因为手指很粗,屏幕很精密,事实上所有手指任何时刻都在移动,所以获取触发事件的手指没有意义。

参考