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
: 无法获取。因为手指很粗,屏幕很精密,事实上所有手指任何时刻都在移动,所以获取触发事件的手指没有意义。
参考
- 图解 Android 事件分发机制 注意博主有个别图标错了,本文已更正。
- 扔物线学堂