五个需求
直接传给目标View
先实现一个最简单的需求:Activity中有一堆层层嵌套的View,有且只有最里边那个View会消费事件(黄色高亮View代表可以消费事件,蓝色View代表不消费事件):
首先事件肯定得从父辈那里来,因为子View被包裹在里面,没有直接与外界通信的办法,而实际中Activity连接着根View DecorView,它是通往外界的桥梁,能接收到屏幕硬件发送过来的触摸事件。所以事件是从Activity开始,经过一层一层ViewGroup,传到最里边的View,这时只需要一个从外向里传递事件的passEvent(ev)方法,父辈一层层往里调,能把事件传递过去,便完成了需求。如下图所示:
代码如下:
1 | public class MView { |
暂时把Activity当成MViewGroup处理也没有问题,为什么是MViewGroup继承MView而不是反过来,因为MView是不需要child字段的。
从里向外传给目标View
增加一条需求,让情况复杂一点:Activity中有一堆层层嵌套的View,有好几个叠着的View能处理事件:
同时需要增加一条设计原则:用户的一次操作,只能被一个View真正处理(消费)。这样的设计原则是合理的,这样的操作反馈符合用户直觉,很容易理解,正常情况下人只会想一次就做一件事,比如一个列表条目,列表可以点击进入详情,列表上还有个编辑按钮,点击可以编辑条目,这是一个上下两个View都能点击的场景,但用户点一个地方,肯定只想去做一件事,要么进入详情,要么编辑条目;再比如在一个可点击Item组成的列表里(比如微信的消息界面),Item可以点击进入某个聊天,列表还能滑动上下查看,如果让Item和列表都处理事件,那在滑动的时候,可能会进入很多用户并不想去的聊天页面。
如果使用直接传给目标View一节中的框架,要遵守这条原则,就需要在每一个可以处理事件的View层级,判断出自己要处理事件后,不继续调用child的passEvent()方法了,保证只有自己处理了事件。但如果真这样实现了,处理事件的顺序并不对:还是上面的列表,当用户点击按钮想编辑条目的时候,点击事件先传到条目,如果在条目中判断需要事件,然后把事件消费了不传给子View,用户就永远点不开编辑条目了,而且换个角度看更加明显,用户肯定希望点哪,哪儿离手指最近的控件被触发。所以实现新增需求的一个关键是找到那个适合处理事件的View,通过对业务场景进行分析,得到答案是那个最里面的View适合处理事件。这就不能等parent不处理事件了才把事件传给child,应该反过来,需要事件的处理顺序从里向外:里边的child不要事件了,才调用parent的passEvent()方法把事件传出来。
于是得加一条向外的通道,只能在这条向外的通道上处理事件,前面向里的通道什么都不干,只管把事件往里传。所以这时有了两条通道,改个名字,向里传递事件是passIn()方法,向外传递并处理事件是passOut()方法。
代码如下:
1 | public class MView { |
如前所述,我们希望passIn()的时候只传递事件,希望在passOut()的时候每个View决定是否要处理事件,并进行处理,而且在处理事件后,不再调用parent的passOut()方法把事件传出来。这其中包含了两类职责:一类是事件传递控制逻辑,另一类是事件处理钩子,其中事件传递控制逻辑基本不会变化,但事件处理的钩子中可能做任何事情。我们需要把不同职责的代码分开,把变化的和不变的分开,减少框架使用者的关注点。我们用一个叫做dispatch()的方法单独放事件传递的控制逻辑,用一个叫做onTouch()的方法作为事件处理的钩子,而且钩子有一个返回值,表示钩子中是否处理了事件:
1 | public class MView { |
这样写完整个行为其实没有变化,但控制逻辑集中在dispatch()中,一目了然,onTouch()单纯是一个钩子,框架使用者只需要关心这个钩子和它的返回值,不用太关心控制流程,另外连parent也不需要了。
区分事件类型
上文的实现看上去已经初具雏形了,但其实连开始提的那条原则都没实现完,因为原则要求一次操作只能有一个View进行处理,而我们实现的是一个触摸事件只能有一个View进行处理。
这里就涉及到一次触摸操作和一个触摸事件的区别。假设还没有触摸事件的概念,要怎么区分一次触摸操作呢?
把触摸操作细分一下,大概有按下动作、抬起动作、与屏幕接触时的移动和停留动作,很容易想到,要区分两次触摸操作,可以通过按下和抬起动作进行区分,按下动作开始了一次触摸操作,抬起动作结束了一次触摸,按下和抬起中间的移动和停留都属于这一次触摸操作,至于移动和停留是否要区分,目前没有看到区分的必要,可以都作为触摸中来处理。
于是在一次触摸操作中就有了三种动作的类型:DOWN/UP/ING,把ING改个名字叫MOVE。每个触摸动作会在软件系统中产生一个同样类型的触摸事件,所以最后,一次触摸操作就是由一组从DOWN事件开始、中间是多个MOVE事件、最后结束于UP事件的事件流组成。于是设计原则更确切地说就是:一次触摸产生的事件流,只能被一个View消费。
在上一节框架的基础上把一个事件变成一组事件流其实非常简单:处理DOWN事件时跟前面处理一个事件时一样,但需要同时记住DOWN事件的消费对象,后续的MOVE/UP事件直接交给它即可,代码如下:
1 | public class MViewGroup extends MView { |
代码只多做了两件事:增加了一个isChildNeedEvent状态,对子View是否处理了DOWN事件进行记录,并在其它触摸事件时使用这个状态;在收到DOWN事件的最开始和收到UP事件的最后,重置状态。此时框架使用者还是只需要关心onTouch()钩子,在需要处理事件时进行处理并返回true,其它事情框架都做好了。
增加外部事件拦截
上面的框架已经能完成基本的事件分发工作了,但有如下需求:在可滑动View中有一个可点击View,需要让用户即使按下的位置是可点击View,再进行滑动时,也可以滑动外面的的可滑动View。
这个需求其实非常常见,比如所有“条目可点击的滑动列表”就是这样的(微信/QQ聊天列表)。假如使用上面的框架,可滑动View会先把事件传到里边的可点击View,可点击View知道有事件传入,自己又可点击,于是处理事件,这样外面的可滑动View就永远无法处理事件,也就无法滑动了。所以直接使用现在的模型去实现“条目可点击的滑动列表”会永远滑动不了。
假设有如下思路:让里面的可点击View去感知(层层往上找)自己是不是被一个能消费事件的View包裹,是的话自己就不消费事件了;或者调整dispatch()方法,让它不是只能往里传递事件,而是在自己能消费事件的时候把事件给自己。
这样的思路是不行的,子View层层反向遍历父View不是好的实现,更主要的是不能外面是可以滑动的View,里边View的点击事件就全部失效。从头开始考虑,当用户面对一个滑动View里有一个可点击View,当他触摸在可点击View上时,他是要做什么?显然只有两个可能性,用户想点这个可点击View或者用户想滑动这个可滑动View。但是当用户刚用手指接触的时候,也就是DOWN事件来的时候,是不能判断用户想干什么的,所以客观条件下,不可能在DOWN事件传过来的时候,判断出用户到底想做什么,两个View其实都不能确定自己是否要消费事件。
我们可以多等一会儿,看用户接下来的行为能匹配哪种操作模式:
- 点击操作模式:用户先DOWN,然后MOVE很小一段,也不会MOVE出这个子View,关键是比较短的时间就UP;
- 滑动操作模式:用户先DOWN,然后开始MOVE,这时候可能会MOVE出这个子View,也可能没有,但关键是比较长的时间也没有UP,一直在MOVE。
所以结论是只有DOWN不行,还得看接下来的事件流,再多考虑一个长按的情况,总结如下:
- 如果在某个短时间内UP,则是点击里边的View;
- 如果在比较长的时间后UP,但没怎么MOVE,则是长按里边的View;
- 如果在比较短的时间MOVE比较长的距离,则是滑动外面的View。
看上去目标View判定方案很不错,但现有的事件处理框架实现不了这样的判定方案,至少存在以下两个冲突:
- 子View和父View都无法在DOWN的时候判断当前事件流是不是该给自己,所以一开始它们都只能返回false。但为了能对后续事件做判断,其实是希望事件继续流过它们,按照当前框架的逻辑,又不能返回false;
- 假设事件会流过它们,当事件流了一会儿后,父View判断出这符合自己的消费模式,于是想把事件给自己消费,但此时子View可能已经在消费事件了,而目前的框架是做不到阻止子View继续消费事件的。
要解决以上冲突,需要对上一节的事件处理框架进行修改,首先看第二个冲突,解决它的一个直接方案是调整dispatch()方法在传入事件过程中,不只传递事件了,还可以在传递事件前进行拦截,能够看情况拦截下事件并交给自己的onTouch()处理。基于这个解决方案,大概有以下两个改动相对小的调整思路:
- 当事件走到可滑动父View的时候,它先拦截并处理事件,并且把事件保存起来;当经过了几个事件后,如果判断出符合自己的消费模式,则直接开始自己消费,并且不用继续保存事件了,如果判断出不是自己的消费模式,再把所有保存的事件给子View,触发里层的点击操作。
- 所有的View只要可能消费事件,就在onTouch()里对DOWN事件返回true,不管是否识别出当前属于自己的消费模式;当事件走到到可滑动父View的时候,它先把事件往里传,内层可能会处理事件,也可能不会,可滑动父View暂时不关心;然后看子View是否处理事件,假如子View不处理事件,父View直接处理事件,假如子View处理事件,可滑动父View则观察事件是否符合自己的消费模式,一旦发现符合,就把事件流拦截下来,即使子View也在处理事件,父View也不往里disptach事件了,而是直接给自己的onTouch()。
两个思路中,第一个思路外层的父View先拦截事件,如果判断拦错了,再把事件往内层发;第二个思路外层的父View先不拦截事件,在判断应该拦截时突然把事件拦下来。两个思路中,思路一问题比较明显:父View把事件拦截下来,然后发现拦截错了再给子View,但其实子View并不一定能消费事件,等到子View不处理事件,又把事件还给父View,父View还得继续处理事件。整个过程不仅繁琐,而且会让开发者感觉到别扭。思路二相对正常,只有一个问题(下一节说明),而且框架要做的改变也很少:增加一个拦截方法onIntercept()在父View往子View dispatch事件前,开发者可以覆写这个方法,加入自己的事件模式分析代码,并且可以在确定要拦截的时候进行拦截。在确定自己要拦截事件时,即使子View在一开始消费了事件,也不把事件往内层传递了,而是直接给自己的onTouch()。如下图所示:
修改代码如下所示:
1 | public class MViewGroup extends MView { |
- 不仅是在DOWN事件的dispatch()前需要拦截,在后续事件中,也需要加入拦截,否则无法实现中途拦截的目标;
- 在某一个事件判断拦截之后,并不需要在后续事件中再判断一次是否要拦截,我们希望在一次触摸中,尽可能只有一个对象去消费事件,决定之后不会改变,所以增加一个isSelfNeedEvent记录自己是否拦截过事件,如果拦截过,后续事件直接交给自己处理;
- 后续事件时,子View没有处理事件,外面也不会再处理了,因为只能有一个View处理(Actvity会处理这样的事件)。
这次的修改只是增加了一个事件拦截机制,对于框架的使用者来说,关注点还是非常少:重写onIntercept()方法,判断什么时候需要拦截事件,需要拦截时返回true;重写onTouch()方法,如果处理了事件,返回true。
增加内部事件拦截
上面的处理思路虽然实现了需求,但可能会导致一个问题:里边的子View接收了一半的事件,可能已经开始处理并做了一些事情,父View忽然不把后续事件给它了,会不会违背用户操作的直觉?出现奇怪的现象?
这个问题需要分两类情况讨论:
反馈交互未开始或可取消
内层的View接收了一半事件,但还没有真正开始反馈交互,或者在进行可以被取消的反馈。
对于一个可点击的View,View的默认实现是只要被touch了,就会有pressed状态,如果设置了对应的background,View则会有高亮效果。但这种高亮即使被中断也没事,不会让用户感觉到奇怪,但值得注意的是,如果只是直接不发送MOVE事件了,这会有问题,像View按下高亮,不传MOVE事件,那谁来告诉内层的子View取消高亮呢?所以需要在中断的时候也传一个结束事件。但是,这无法直接传一个UP事件,这样就匹配了内层的点击模式,会直接触发点击事件,这显然不是我们想要的。于是外面需要给一个新的事件,这个事件的类型叫取消事件,即CANCEL。
所以对于这种简单的可被取消的情况,可以做如下处理:在确定要拦截的时候,把真正的事件转发给自己onTouch()的同时,另外生成一个新的事件发给自己的子View,事件类型是CANCEL,它将是子View收到的最后一个事件;子View可以在收到这个事件后,对当前的一些行为进行取消。
反馈交互已开始或不可取消
内层的View接收了一半事件,已经开始反馈交互,这种反馈最好不要取消,或者取消了会显得很怪。
这种情况下事情会复杂一些,而且这样的场景发生的情况也很多,形式也多种多样,不处理好可能会无法实现某些功能,下面举两个例子
- 在ViewPager里有三个page,page里是ScrollView,ViewPager可以横向滑动,page里的ScrollView可以竖向滑动。如果按前面逻辑,当ViewPager把事件给ScrollView后,它也会观察,如果一直是竖向滑动,ViewPager不会触发拦截事件,但如果竖着滑动时,产生抖动,开始横滑(或者只是斜滑),ViewPager会拦截事件,于是在斜滑一定距离后,忽然发现,划不动ScrollView了,而ViewPager开始滑动。原因便是ScrollView的竖滑被取消了,ViewPager把事件拦下来,开始横滑。这个体验还是比较怪的,会有种过于灵敏的感觉,让用户只能小心翼翼地滑动。
- 在一个ScrollView里有一些按钮,按钮有长按事件,长按再拖动可以移动按钮(更常见的例子是一个列表,里边的条目可以长按拖动),同样按前面的逻辑,当长按后准备拖动按钮时,如何保证不让ScrollView把事件拦下来呢?
从用户的角度看,当内层的View已经开始做一些特殊处理了,外层的View不应该把事件抢走。这需要内层的View来做出判断,告知外层。有以下几种方式:外层抢夺事件之前先问一下内层能不能抢;内层在确定这个事件不能被抢之后,从dispatch方法返回一个特别的值给外层(之前只是true和false,现在要加一个);内层通过别的方式通知外层不要抢夺事件。这三种方式都可以解决问题,但第三种方式最为简单直接,而且对框架没有过大的改动,Android也使用了这个方式,父View给子View提供一个方法requestDisallowInterceptTouchEvent(),子View调用它改变父View的状态,同时父View每次在准备拦截前都会判断这个状态(当然这个状态只对当前事件流有效)。这种情况还得再注意一点,它应该是向上递归的,也就是在复杂的情况中,可能有多个上级,当里层的View决定要处理事件而且不准备交出去的时候,外层所有的父View都应该不拦截该事件。
总结如下:
- 对于多个可消费事件的View进行嵌套的情况,如何判定事件的归属会变得非常麻烦,无法立刻在DOWN事件时就确定,只能在后续的事件流中进一步判断;
- 在没判断归属的时候,先由内层的子View消费事件,外层观察,同时双方共同对事件类型做进一步匹配,并准备在匹配成功后对事件流的归属进行争夺,一般由开发者自己根据实际用户体验调整,让父View或子View在最适合的时机准确抢到应得的事件。
此外需要说明的是,父View在拦截事件,把接下来的事件传给自己的onTouch()后,onTouch()只会收到后半部分的事件,这样可能会有问题,所以一般情况是,在没拦截的时候就做好如果要处理事件的一些准备工作,以便之后拦截事件了,只使用后半部分事件也能实现符合用户直觉的反馈。
再次修改代码如下所示:
1 | public interface ViewParent { |
增加了CANCEL事件和requestDisallowInterceptTouchEvent机制。在发出CANCEL事件时有一个细节:没有在给child分发CANCEL事件的同时继续把原事件分发给自己的onTouch。这是源码中的写法,可能是为了让一个事件只能有一个View处理,避免出现bug。在实现requestDisallowInterceptTouchEvent机制时,增加了ViewParent接口,不使用这种写法也可以,但使用它从代码整洁的角度看会更优雅,比如避免反向依赖,而且这也是源码的写法。
虽然目前整个框架的代码有点复杂,但对于使用者来说,依然非常简单,只是在上一节框架的基础上增加了:
- 如果View判断自己要消费事件,而且执行的是不希望被父View打断的操作时,需要立刻调用父View的requestDisallowInterceptTouchEvent()方法;
- 如果在onTouch方法中对事件消费并且做了一些操作,需要在收到CANCEL事件时,对操作进行取消。
到这里,事件分发的主要逻辑已经讲清楚了,不过还差一段Activity中的处理,其实它做的事情类似ViewGroup,只有这几个区别:
- 不会对事件进行拦截;
- 只要有子View没有处理的事件,它都会交给自己的onTouch()。
Activity代码如下:
1 | /** |
到此,写了一个比较粗糙的框架,源码的主要逻辑与它的区别不大,具体区别大概有:TouchTarget机制、多点触控机制、NestedScrolling 机制、处理各种 listener、结合View的状态进行处理等。
事件分发其实很简单,它的关键不在于不同的事件类型、不同的View种类、不同的回调方法、方法不同的返回值对事件分发是怎么影响的,关键在于它要实现什么功能、对实现效果有什么要求、使用了什么解决方案,从这个角度,就能清晰而且简单地把事件分发整个流程梳理清楚。
事件分发要实现的功能是:对用户的触摸操作进行反馈,使之符合用户的直觉。从用户的直觉出发能得到如下两个要求:
- 用户的一次操作只有一个View去消费
- 让消费事件的View跟用户的意图一致
参考资料:
RubiTree 【透镜系列】看穿 > 触摸事件分发 >