Touch事件概述及自定义事件处理的被忽视的重要细节

从接触视图的自定义手势处理这块知识到现在也有1年多,期间陆陆续续在实际开发中实现了一些自定义组件,但是不如一些每天都需要用到的知识点,所以久了一些重要的原理和技巧就淡忘了,最近终于抽出时间来整理。没有按照以往贴上源码挨着顺序分析,因为网上一搜就有很多讲解得很好的文章,所以本篇主要的目的是便于自我的一个快速地回顾和印象增强。文章如同标题所述主要分为两部分,前半部分主要是一个流程和Touch相关知识点的概述,期间罗列了一些常见例子帮助分析,差不多都是点到为止,后半部分则是在实际开发中,对手势处理的一些很容易被忽视的细节点,当然并不是说不了解这些细节点就无法进行自定义处理逻辑,但是掌握有助于理清处理手势的一些步骤,因此从整体而言,本篇也算是一篇不错的初学者入门篇。

MotionEvent

每一个由设备上的触摸屏产生的事件都被包裹成MotionEvent中的一种,
MotionEvent提供了关于这个事件我们所需要的全部信息,这些信息包括事件所产生的动作以及关于本事件的一些额外的元信息,比如说触摸点的位置、事件发生时有多少根手指处于屏幕上、事件发生的时间等。

系统提供了一些常用的事件动作类型,光看其名字就能猜出其本身的含义。例如ACTION_DOWNACTION_UP
这两个动作是第一根手指在屏幕上按下,然后释放手指所产生的;ACTION_MOVE产生于手指在屏幕上拖拽时;ACTION_POINER_DOWNACTION_POINTER_UP这两个动作会在第二根或更多手指跟屏幕交互时所产生,在处理多点触控时会用到;最后一个是ACTION_CANCEL,这个动作会在一些特殊的情形下用到,在接下来的讲解中会遇到。主要的用法是当事件起初被一个视图处理,之后又交给其他视图处理时,就会用到。另外需要强调的是,android只定义了一种手势,此手势的全部事件组成发生在ACTION_DOWNACTION_UP之间,当我们需要处理一个新的手势时,
ACTION_DOWN就是这个新手势的起始,之后手势的状态会一直由不同的移动事件所维护着,直到ACTION_UP发生,这有点像重新洗牌,再开一局的意思,整个过程可能会反复多次。

事件的传递流程

对于每一个由硬件和底层框架产生的事件,最先被发送到Activity中,应用的哪个Activity在前台的最顶部,哪个Activity就会接收触摸事件,优先于应用中的其他任何组件,该过程是经由框架调用Activity的dispatchTouchEvent()来完成的,因此我们可以重载这个方法来检查各种移动事件(但不建议在该方法里面做过多的事情)。从这个方法开始,这些事件会开启其旅程,由Activity开始往下传递给视图结构树,而在视图结构树中会由上到下传递,事件首先由窗口服务端发送到Activity里面,再由Activity发送给RootView,RootView是contentView的顶层View,因此事件会分发给我们自己布局中的根ViewGroup,然后依次发送给子视图们,如果这些子视图中也有ViewGroup,就递归地再往下分发,直到事件分发到视图树的最底部,之后事件会反过来由底向上传递。

这里的传递机制是:事件会从上往下再从下往上穿梭,直到某个View宣布其对这个事件感兴趣为止,一旦存在,那么关于某个手势的其他信息就会接踵而来。是否”感兴趣”的具体体现就是onTouchEvent()这个方法,每个View和ViewGroup都有这个函数,其返回一个布尔值,因此我们的自定义视图可以通过重写这个方法并返回true来宣布对到来的触摸事件感兴趣,具体来说就是需在onTouchEvent()中处理 ACTION_DOWN 时返回true,因为如果不宣布对 ACTION_DOWN 感兴趣,系统就会觉得我们对其他事件亦不感兴趣,就效率而言,系统认为他们这么做理所当然,一旦某个特定的View宣布了,那么这个View就是某个手势中其他事件的直接目的地。然后当Android框架就知道了有一个视图会对触摸事件感兴趣,那么它就会中断原本的对触摸事件的响应链。但是如果一个触摸事件成功的传递到了视图的底部,以及在此过程中,没有任何视图对其产生兴趣,此事件就会在视图树中一直往上返回,直到返回到Activity为止。

综上所述,整个视图树的传递流程是,通过View和ViewGroup自顶向下传递,根据是View还是ViewGroup调用其各自的dispatch方法,ViewGroup会把事件交由它的child,child再交给child’s child等等,接下来当事件往上传递时,View和ViewGroup就会调用onTouchEvent()了,简而言之,dispatch一层一层往下调用,onTouchEvent一层一层往上返回。所以我们以前可能这么做过:由于Activity中有一个onTouchEvent()的方法,既然事件经由Activity分发下去,那么Activity中的onTouchEvent()就是我们要监控触摸事件的传递流程的第一站,但是结果却是恰恰相反!如果在Activity的onTouchEvent中监测触摸事件,这个地方其实是触摸事件到达的终点,而且大多数情况下我们根本就监测不到,因为如果触摸事件被中间过程中的某个View消费了,那么事件终点就会停到那个View处了,也就是说那个View上面的onTouchEvent就不会被调用了,这点需要谨记。

另外,前面提到的onTouchEvent()需要重写View,所以我们可以用另外一种方式监测触摸事件或者是对事件做交互处理,这种方法不必我们去派生View或ViewGroup的子类,而是直接调用其onTouchListener()即可,onTouchListener()就跟onTouchEvent()没啥两样,在此方法中,照样可以返回true来消耗某些事件,当我们对待某些情形时,使用这个方法是个不错的选择。

事件的分发:dispatchTouchEvent()

前面我们已经把触摸的流程地梳理完了,这节来看看视图是如何分发这些事件的。View在这个过程中的逻辑要比ViewGroup简单。View的dispatchTouchEvent()只需要干两件事儿,第一件是检查是否有TouchListener注册在这个View中,如果有,就会先对此监听器进行检查,看看其是否想要消费此次事件,如果触摸监听器不消费事件或者传进来的事件跟要监听的事件无关,接下来该View的onTouchEvent()就要被调用了,如果在onTouchEvent()中也没有返回true,那么事件就会返回到视图树上的上一层,在一般的View中,就只有这两个地方来处理事件。

ViewGroup的机制就有点复杂了,它需要对自己的child进行遍历和迭代,来确定哪些child有可能对此事件感兴趣,这个步骤是在ViewGroup的dispatchTouchEvent()中实现的,ViewGroup会根据触摸的位置来判断,有哪些child可以代表此位置,触摸位置处于child的边界内才说明触摸事件与此View相关,如果此child不只1个,比方说两个有重叠部分的View,ViewGroup就会逆序遍历这些child-View(逆序是指按照被加入到ViewGroup中的顺序的逆序),使得这些View有处理事件的机会,通过父View的dispatch,首先是第一个child-View处理事件,之后再轮到第二个child-View,前提是第一个child-View没有消耗掉事件。

事件的拦截:onInterceptTouchEvent()

除了正常的事件分发,ViewGroup还提供了onInterceptTouchEvent()方法用以对触摸事件进行中断或者窃取。当由于手势的特殊需求,使得ViewGroup要停止将事件分发下去,即停止将事件分发给原本将要消耗此事件的child转而直接让自己来处理事件,就可以在不打乱事件分发的流程的前提下使用onInterceptTouchEvent来实现。举个例子,我们有一个ScrollView,这个ScrollView里面有一些子视图,这些子视图也同样需要交互,例如Button,这些Button肯定是要能被点击的,所以他们被注册点击listener。一旦我们的手指开始做一个滚动的动作,ScrollView就需要优先Button来响应事件,处理视图内容的滚动。

实际上ViewGroup本来就在监控各种触摸事件,尽管事件最终是要被分发到不同的child身上,不过一旦ViewGroup监测到了一个特定手势例如拖拽,就不能让child来响应了,于是就会触发这种中断把事件直接交给自己来处理,那么,作为开发者的我们,就可以好好利用这种机制。

事件的屏蔽:requestDisallowTouchIntercept()

前面讲的大部分其实都是框架自己干的事情,这些都不用我们去实现,我们只需要把系统的流程搞清,顺着系统的意思重写就行,但是有时候也有可能出现parent和child都需要拦截事件的情况例如同向嵌套滚动,因此在ViewGroup提供了一个额外的标识,用来打断这个事件拦截的逻辑,对外的接口是一个又长又拗口的方法:requestDisallowTouchIntercept()。它是一个由父视图调用的方法,调用时只用传入一个布尔参数即可,如果传的是true,那么此时就剥夺了ViewGroup对当前手势的中断能力。

例如前面提到的嵌套滚动例子,具体需求就是:如果我们自定义的子视图出于某些原因,也需要处理一些拖拽事件时,且子视图外面包裹的是ScrollView,我们希望暂时是由子视图先处理拖拽,所以需要阻止ScrollView去处理拖拽。因此我们可以把这个标识先置为true一段时间,然后待子视图不再需要后再重置该标识。但需要注意的是,这个标识默认只对当前的手势有效,意味着每次手势开始,这个标识不会像某些全局变量一样,所以不要期待:我们对它说”不要屏蔽我的事件”,之后它就会永远帮我们屏蔽一样。

事件处理的重写

当需要重写onTouchEvent()方法时, 我们可能只需想处理一到两个特定的事件,也可能只是想监听触摸事件的流向,抑或是想要简单地计算一下手指移动的距离等,并不是要处理所有的事件,这样的话最好是调用一下superonTouchEvent(),而不是把所有事件都交给自己。因为View在onTouchEvent()方法中做了很多事情来维护自身的状态, 例如有不可按下的标识、不可更改可绘制对象等,如果我们不调用super的方法就失去了这些特性,有时候产生很多相关的疑惑都与此有关。因此通常当我们处理事件时,一旦遇到不想处理的事件时, 与其明确的返回一个true或者false, 不如返回一个super.onTouchEvent()

另外,对于ACTION_CANCEL,大多数情况下是跟弹起是放在一起处理的。

事件的一些检测常量:ViewConfiguration

ViewConfiguration类包含许多常量,在自定义手势处理时非常有用,其中大多数常量是跟触摸事件有关的(但也不全是,有些也会跟画图或其他方面相关),这些常量单位都是dp,与分辨率无关,所以我们就无需自己去找一些随机的像素点数来充当自己的slop检查。本节讲解几个实际使用的最重要的常量。

touchSlop

首先是touchSlop,通过getScaledTouchSlop()可以得到,它是一个决定触摸事件是否能从轻触转换为拖拽的阈值。通常,当我们的手指停留在屏幕上时,即使尽可能的让手指不乱动, 我们也会发现,不管手指有多么的静止, 还是会看到有相当多的ACTION_MOVE传递到View中,因为我们的手指其实是个很容易颤动的东西,在将其转换为在屏幕上表示位置的一个像素点时,即使仅仅一点点的振动,系统为了探知手指的位置,也会产生大量的ACTION_MOVE事件。系统很多视图例如ScrollView都使用了touchSlop来与手指移动的距离相比来决定什么时候应该开始滚动或者类似的事情。

Fling

系统还给我们提供了用于检查Fling的常量——最小Fling速度常量和最大Fling速度常量。Fling具体是指当手指在屏幕上飞快的一划,手指离开屏幕时,屏幕的滚动还没有停止,即一种自动化的滚动。例如我们”Fling”了一个可滚动的列表时,由于我们在手指离开屏幕之前并没有让其停下,框架就会把此手势认为是Fling,因此我们需要根据手势达到的速度来让滚动滚得自然一些。

scaledPagingTouchSlop

scaledPagingTouchSlop在ViewPager中有被用到,该常量跟前面讲到的touchSlop是有区别的,当前这个touchSlop指的是横向滑动翻页的touchSlop,而不是我们常见的用于滚动的touchSlop,但也不是所有横向的slop都用这个常量,例如横向的ScrollView用的还是第一种slop来做检查,因此此处的slop仅仅用于ViewPager之类的视图中,起初设计这两个不同的常量的主要原因是,当把ScrollView放到ViewPager中时,怎样才能使两者都工作的好, 两种不同的slop检查就很容易判断出到底哪个视图的手势需求优先。

多点触摸事件

index跟id的不同之处在于,触摸点的id是固定的,但是其索引就不是的, 举个例子,例如当前有3个手指放在了屏幕上,那么这3个触摸点的id依次是0,1,2, 同样其index也是0,1,2, 但是一旦我们松开第二根放下的手指, 那么屏幕上剩下的触摸点的id还是0跟2, 但是其相对应的index就会变成0跟1。index更多的是在指这儿还有多少POINTER,而id就是个稳定的值,用于对按下的触摸点进行跟踪记录,具体可以参见前面一篇关于多手指处理的笔记:多手指Touch变化处理原则基础

事件的混合

默认情况下,当我们从View中获取并处理触摸事件时,实际上并没有监测到每一个单独的事件,当我们的手指在屏幕上划来划去时,虽然移动事件的确分发给了视图,但是这些事件是成批处理过之后的事件,并不是说这些触摸点信息就丢失了,这点在应用中体现在每次有事件到来时,在事件回调方法中得到的事件是关于事件成批处理中的最近发生的事件,当我们请求获取此次触摸事件中X,Y的位置或者其他请求时, 最终得到的结果都是从成批事件处理后的最近发生的事件。

但是如果某些时候我们需要监测系统生成的每一个事件,例如可能我们要用这些来构建一个路径或者些其他,反正就是想要得到每一个单独的事件,通过MotionEvent对象可以得到我们想要的一切, 该对象的方法除了有返回x,y、发生时间、每一个单独事件的所有元数据以外,还可以得到此次成批处理中一共有多少事件, 这样我们就可以知道所有需要的事件了。

触摸代理

我们以一个例子切入:有一个ListView,由于设计需求导致其item中有一个图标太小以至于很难触摸的到,但是我们又希望用户可以点击到这个图标,在多数情况下,我们可能会规避走到这一步,比方说那把视图设置得大一点,但如果必须这样,那可以想到的解决办法就是在ListView的Item中的父视图指定一片稍大的区域当做这个图标的代理,然后把此区域中的所有触摸事件都传给那个图标。其实系统早已针对这种情况给出了解决方案,我们只需调用父视图的setTouchDelegate方法,传入一个用供代理事件的矩形区域和需要被代理的child来初始化的触摸代理对象就可以了。在触摸代理内部它会对每一个进入到父视图的事件进行检查, 判断此事件的位置是否落在了定义的矩形区域内, 是的话, 那就把事件的坐标稍作处理,用触摸代理传送到那个按钮中, 至于此处按钮会有怎样的反应,那就要看按钮自身的触摸事件处理过程了。

当然,除了使用TouchDelegate,还有另外一种解决方案,就是利用event.offsetLocation对事件坐标进行“偏移”,然后调用需要被触摸的视图的dispatch将事件分发给它,但是这涉及到派生View或ViewGroup。两种方案在实际开发中需要好好斟酌。

事件自定义处理的重要细节

为了方便展开,约定从最外层 ViewGroup(0)最内层 View(n) 所形成的事件传递链为:

ViewGroup(0) ->...-> ViewGroup(n-1) -> View(n)

事件的传递方向

在不拦截的情况下,若存在传递链的某节点 t 可以消费事件,[0, n],而[t+1,n]范围的所有节点都不消费事件,那么手势的第一个事件ACTION_DOWN的传递流程为0 -> ... -> n,再n -> ... -> t;除此之外的剩余事件则只会从0 -> ... -> t -> t

其中0 -> ... -> n的事件出现在dispatchTouchEventonInterceptTouchEvent两个方法,而n -> ... -> t的事件出现在onTouch/onTouchEvent方法。

原因:依据ViewGroup的dispatchTouchEvent源码得到以下伪代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public boolean dispatchTouchEvent(MotionEvent ev) {
...
if (!intercepted) {
...
if (actionMasked == MotionEvent.ACTION_DOWN) {
...
for(children) {
...// 注意这一句传入的第三个参数为child,而下方同方法对应的参数为null
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
...
// addTouchTarget方法会对mFirstTouchTarget赋值
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
...
}
}
...
}
...
}
if (mFirstTouchTarget == null) {
// 这里传递null使得ViewGroup的流程走向super.dispatchTouchEvent
// 从而调用到ViewGroup的onTouch相关方法
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
...
// 具体流程转接下来的结论2的代码片段
}
...
}

在未拦截的前提下,对于ACTION_DOWN事件,ViewGroup会调用dispatchTransformedTouchEvent先将其分发给对应的child,dispatchTransformedTouchEvent内部会触发child分发事件给child的child,这样依次递归直到最内层View。这样传递链的第一部分0 -> ... -> n就形成了.

由于从t+1到n的所有节点都不消费事件,所以child依次对其上层返回false,即dispatchTransformedTouchEvent返回false,这就导致了上述代码中的addTouchTarget方法无法得到调用,关键字段mFirstTouchTarget就始终为null,ViewGroup就没有对应的供分发手势的target-child,因此ViewGroup通过dispatchTransformedTouchEvent调用自己的onTouch相关方法,由于节点t可以消费事件,所以onTouch相关方法返回了true。这样传递链的第二部分n -> ... -> t也形成了。

同时,接下来的事件非intercepted也非ACTION_DOWN,流程直接走向if (mFirstTouchTarget == null) {...},也即是传递链的第三部分0 -> ... -> t -> t形成了。

onTouch相关方法的返回值的具体意义

在整个传递链没有节点拦截的前提下,如果某节点t,[0, n]需要消费事件,那么onTouch相关方法只需要在ACTION_DOWN事件时返回true就可以持续收到整个手势的所有剩余事件,不论当这些剩余事件到来时返回true还是false。但是当在ACTION_DOWN返回true,剩余事件返回false时,虽然节点t-1到节点0所有的节点的onTouch相关方法依然不会被回调,但是Activity中的onTouchEvent方法却是可以收到这些事件的。

原因:++从根本来讲,手势 剩余 事件在视图层级能否传递的唯一依据是当前视图是否拥有供自己分发事件的target-child++,否则便会将事件分发给自己的onTouch相关方法。注意前面的“剩余”两个字,意思就是第一个事件ACTION_DOWN不受target-child影响,并始终会从上传递至下递归到最根部的视图,然后从最根部的视图开始向上执行自己的使命:寻找从传递链尾部到首部第一个可以消费事件的节点,一旦发现(即onTouch相关方法返回true,将该节点约定为t),那么节点t-1的target-child就不为空了,并且节点t-1向t-2返回了true,这样节点t-2的target-child也不为空,以此类推,直到传递链的首部,节点0到节点t-1所有节点的target-child都指向了其下一个节点。

也就是说,target-child生成的依据是手势的第一个事件ACTION_DOWN。对于其余事件,与ACTION_DOWN的流程不一致。所以对于接下来的剩余事件传递到传递链底部并向上返回时,target-child并不受返回值的影响。

而对于Activity,其分发手势事件的方法dispatchTouchEvent与ViewGroup的完全不一致,其逻辑表现为:

1
2
3
4
if (decor.dispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);

上面是一段伪代码,其中的decor指代的是整个视图层的顶层容器。所以只要事件回传到传递链的首部是false时,Activity就会调用它的onTouchEvent方法。也就是说,Activity的onTouchEvent被回调与视图层是否有事件消费没关系,而仅于视图层返回到Activity的值有关系。这也是为什么消费手势的节点返回false,Activity的onTouchEvent被回调的原因。

从onTouch相关方法看,拦截事件的时机点的不同对流向自己的事件的影响

当传递链的某节点 t 需要拦截事件,[0, n],

  • 若该事件是手势的第一个事件即ACTION_DOWN,也就是说事件还未传递到范围在(t, n]的节点,那么这个节点 t 的onTouch相关方法收到的是这个手势的完整事件,包括这个ACTION_DOWN
  • 若该事件不是第一个事件例如是ACTION_MOVE,也就是说至少事件ACTION_DOWN已经去过(t, n]的节点了,那么当前事件会被转化为ACTION_CANCEL分发child,所以节点 t 的onTouch相关方法收到的事件是除了当前这个事件,即将到来的所有剩余手势事件。

原因:首先要明白ACTION_CANCEL生成的原因。对于一个正在接收事件的View,在正常情况下,它会持续接收下去并最终收到ACTION_UP事件,以此来重置自己一些与手势相关的字段或UI状态,但是一旦在此期间其上一个节点拦截的后续本应传递给自己的事件,当前节点便无法获知自己手势的结束导致状态混乱,所以系统强制那个拦截事件的节点需要告知其下一个节点它的事件被我拦截了,以让下个节点根据这个状态来重置或处理相关操作,拦截事件的节点告知其下一个节点的这个状态便是ACTION_CANCEL

因此,如果第一个事件本身才刚传递到节点t,节点t决定拦截就不需要告知节点t+1,这样第一个事件就不需要转化为ACTION_CANCEL,也就是说节点t的onTouch相关方法收到的是这个手势的完整事件。而如果节点t决定拦截时当前事件不是第一个事件,那么它就需要告知节点t+1自己需要拦截事件了,请它做好相关善后工作,因此当前事件被转化为ACTION_CANCEL,节点 t 的onTouch相关方法收到的事件是除了当前这个事件,即将到来的所有剩余手势事件。

从拦截方法看,拦截前后流向自己的事件的变化

一旦传递链的某节点 t,[0, n]拦截了事件或者仅节点 t 可以响应事件,那么接下来的该手势的其余事件传递到节点 t 时将不会再传递给onInterceptTouchEvent转而直接传递给其onTouch相关方法。

原因:在ViewGroup的事件分发方法中,事件能够流向onInterceptTouchEvent的前提条件是满足当前事件是手势的第一个事件或者存在供ViewGroup分发手势的target-child两个条件的任中之一。而其中的target-child即源码中的字段mFirstTouchTarget是当ViewGroup的当前事件分发给child后、child通过返回值告知ViewGroup表示child自己或child自己的child(往下递归)可以消费事件时,ViewGroup为其赋值为这个child的。因此,

  • 如果第一个手势事件一开始被当前节点t拦截,当前事件根本没有机会传递下去,target-child得不到赋值机会,那么接下来的该手势的其余事件顺理成章地不会再过问节点t的onInterceptTouchEvent了;

  • 如果就算第一个手势事件未被节点t拦截,只要节点t+1到节点n中的所有节点都不消费事件,那么节点t的target-child依然为null,接下来的该手势的其余事件照样不会再过问节点t的onInterceptTouchEvent了;

  • 再退一步讲,在节点t决定拦截时,当前事件之前的多个事件已经传递了下去且节点t之后存在消费事件的节点,也就是说节点t的target-child不为null,但是决定了拦截的这一刻,事件分发方法dispatchTouchEvent源码中除了将当前这个事件转化为ACTION_CANCEL并传递给target-child外,同时还会清空节点t的节点ttarget-child。这样接下来的该手势的其余事件仍然不会再过问节点t的onInterceptTouchEvent了;

禁止parent拦截事件的方法的正确使用方式

节点t可以通过调用节点t-1的requestDisallowInterceptTouchEvent(true)方法来禁止t-1拦截将要传递给t的事件,但是该方案对于手势起始事件ACTION_DOWN是无效的。也就是说,如果节点t-1在ACTION_DOWN时就拦截了事件,节点t是无法通过该方案禁止的,因为根本就没机会收到事件。进而推导出,requestDisallowInterceptTouchEvent(true)生效的前提是节点t本身就收到过事件,只是为了确保整个手势后续的事件都能收到,节点t可以通过该方案达到目的。

原因:首先,ViewGroup的拦截事件方法onInterceptTouchEvent是由requestDisallowInterceptTouchEvent(true)对应的状态FLAG_DISALLOW_INTERCEPT决定的,默认条件下,这个状态是关闭的,所以ViewGroup可以对即将传递给child的所有事件进行拦截。

但是在ViewGroup的分发事件方法dispatchTouchEvent中,在满足分发前提下,第一步就是在手势初始事件ACTION_DOWN时重置所有与Touch相关的状态,其中就包含了调用requestDisallowInterceptTouchEvent(true)所开启的状态,这样就使得ACTION_DOWN包括接下来的手势事件总有机会被onInterceptTouchEvent拦截,即requestDisallowInterceptTouchEvent(true)方法在ACTION_DOWN或ACTION_DOWN之前调用是无效的。