【透镜系列】看穿 _ 触摸事件分发 _,android 界面开发框架
如果比较长的时间
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 也在处理事件,它也不往里
disptach
事件了,而是直接给自己的onTouch()
两个思路总结一下:
思路一:外面的父 View 先拦事件,如果判断拦错了,再把事件往里发
思路二:外面的父 View 先不拦事件,在判断应该拦的时候,突然把事件拦下来
这两个思路都要对当前框架做改变,看似差不多,但其实还是有比较明显的优劣的
思路一问题比较明显:
父 View 把事件拦下来了,然后发现拦错了再给子 View,但其实子 View 又并不一定能消费事件,这不就是白做一步吗。等到子 View 不处理事件,又把事件们还给父 View,父 View 还得继续处理事件。整个过程不仅繁琐,而且会让开发者感觉到别扭
所以这个思路不太行,还得是把事件先给子 View
思路二就相对正常多了,只有一个问题(下一节再讲,你可以猜一猜,这里我先当没发现),而且框架要做的改变也很少:
增加一个拦截方法
onIntercept()
在父 View 往里dispatch
事件前,开发者可以覆写这个方法,加入自己的事件模式分析代码,并且可以在确定要拦截的时候进行拦截把分析拦截逻辑抽成一个方法非常合理:什么时候拦,什么时候不拦,内里的逻辑很多,但对外暴露的 API 可以很小,非常适合抽出去
在确定自己要拦截事件的时候,即使里边在一开始消费了事件,也不把事件往里传了,而是直接给自己的
onTouch()
示意图:
于是使用思路二,在「三造」的基础上,修改得到以下代码:
open class MView {open fun dispatch(ev: MotionEvent): Boolean {return onTouch(ev)}
open fun onTouch(ev: MotionEvent): Boolean {return false}}
class MViewGroup(private val child: MView) : MView() {private var isChildNeedEvent = falseprivate var isSelfNeedEvent = false
override fun dispatch(ev: MotionEvent): Boolean {var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {clearStatus()
if (onIntercept(ev)) {isSelfNeedEvent = truehandled = onTouch(ev)} else {handled = child.dispatch(ev)if (handled) isChildNeedEvent = true
if (!handled) {handled = onTouch(ev)if (handled) isSelfNeedEvent = true}}} else {if (isSelfNeedEvent) {handled = onTouch(ev)} else if (isChildNeedEvent) {if (onIntercept(ev)) {isSelfNeedEvent = truehandled = onTouch(ev)} else {handled = child.dispatch(ev)}}}
if (ev.actionMasked == MotionEvent.ACTION_UP) {clearStatus()}
return handled}
private fun clearStatus() {isChildNeedEvent = falseisSelfNeedEvent = false}
override fun onTouch(ev: MotionEvent): Boolean {return false}
open fun onIntercept(ev: MotionEvent): Boolean {return false}}
写的过程中增加了一些对细节的处理:
不仅是在
DOWN
事件的dispatch()
前需要拦截,在后续事件中,也需要加入拦截,否则无法实现中途拦截的目标在某一个事件判断拦截之后,还需要在后续事件中再判断一次是否要拦截吗?
完全不需要,我们希望的就是在一次触摸中,尽可能只有 1 个对象去消费事件,决定是你了,那就不要变
所以增加一个
isSelfNeedEvent
记录自己是否拦截过事件,如果拦截过,后续事件直接就交给自己处理在后续事件时,子 View 没有处理事件,外面也不会再处理了,同样因为只能有一个 View 处理(Actvity 会处理这样的事件,后面会提到)
这一下代码是不是看上去瞬间复杂了,但其实只是增加了一个事件拦截机制,对比上一次试造的轮子,会更容易理解。(要是 Markdown 支持代码块内自定义着色就好了)
而且对于框架的使用者来说,关注点还是非常少
重写
onIntercept()
方法,判断什么时候需要拦截事件,需要拦截时返回true
重写
onTouch()
方法,如果处理了事件,返回true
1.5. 五造:增加内部事件拦截
上面的处理思路虽然实现了需求,但可能会导致一个问题:里边的子 View 接收了一半的事件,可能都已经开始处理并做了一些事情,父 View 忽然就不把后续事件给它了,会不会违背用户操作的直觉?甚至出现更奇怪的现象?
这个问题确实比较麻烦,分两类情况讨论
里边的 View 接收了一半事件,但还没有真正开始反馈交互,或者在进行可以被取消的反馈
比如对于一个可点击的 View,View 的默认实现是只要被 touch 了,就会有
pressed
状态,如果你设置了对应的background
,你的 View 就会有高亮效果这种高亮即使被中断也没事,不会让用户感觉到奇怪,不信你自己试试微信的聊天列表
但一个值得注意的点是,如果你只是直接不发送
MOVE
事件了,这会有问题,就这个按下高亮的例子,如果你只是不传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
把事件拦下来呢?所以这类问题是一定要解决的,但要怎么解决呢
还是先从业务上看,从用户的角度看,当里边已经开始做一些特殊处理了,外面应不应该把事件抢走?
不应该对吧,OK,解决方针就是不应该让外边的 View 抢事件
所以接下来的问题是:谁先判断出外边的 View 不该抢事件,里边的子 View 还是外边的父 View?然后怎么不让外边的 View 抢?
首先,肯定是里边的 View 做出判断:这个事件,真的,外边的 View 你最好别抢,要不用户不开心了
然后里边就得告知外边,你别抢了,告知可以有几个方式
外边抢之前问一下里边,我能不能抢
里边在确定这个事件不能被抢之后,从
dispatch
方法返回一个特别的值给外边(之前只是true
和false
,现在要加一个)里边通过别的方式通知外边,你不要抢
讲道理,我觉得三个方式都行,但第三个方式最为简单直接,而且对框架没有过大的改动,Android 也使用了这个方式,父 View 给子 View 提供了一个方法
requestDisallowInterceptTouchEvent()
,子 View 调用它改变父 View 的一个状态,同时父 View 每次在准备拦截前都会判断这个状态(当然这个状态只对当前事件流有效)然后,这个情况还得再注意一点,它应该是向上递归的,也就是,在复杂的情况中,有可能有多个上级在暗中观察,当里边的 View 决定要处理事件而且不准备交出去的时候,外面所有的暗中观察的父 View 都应该把脑袋转回去
所以,连同上一次试造,总结一下
对于多个可消费事件的 View 进行嵌套的情况,怎么判定事件的归属会变得非常麻烦,无法立刻在
DOWN
事件时就确定,只能在后续的事件流中进一步判断于是在没判断归属的时候,先由里边的子 View 消费事件,外面暗中观察,同时两方一块对事件类型做进一步匹配,并准备在匹配成功后对事件流的归属进行抢拍
抢拍是先抢先得
父亲先抢到,发个
CANCEL
事件给儿子就完了儿子先抢到,就得大喊大叫,撒泼耍赖,爸爸们行行好吧,最后得以安心处理事件
另外有几个值得一提的地方:
这种先抢先得的方式感觉上有点乱来是吧,但目前也没有想到更好的办法了,一般都是开发者自己根据实际用户体验调整,让父亲或儿子在最适合的时机准确及时地抢到应得的事件
父 View 在拦截下事件后,把接下来的事件传给自己的
onTouch()
后,onTouch()
只会收到后半部分的事件,这样会不会有问题呢?确实直接给后半部分会有问题,所以一般情况是,在没拦截的时候就做好如果要处理事件的一些准备工作,以便之后拦截事件了,只使用后半部分事件也能实现符合用户直觉的反馈
在「四造」的基础上,修改得到以下代码:
interface ViewParent {fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean)}
open class MView {var parent: ViewParent? = null
open fun dispatch(ev: MotionEvent): Boolean {return onTouch(ev)}
open fun onTouch(ev: MotionEvent): Boolean {return false}}
open class MViewGroup(private val child: MView) : MView(), ViewParent {private var isChildNeedEvent = falseprivate var isSelfNeedEvent = falseprivate var isDisallowIntercept = false
init {child.parent = this}
override fun dispatch(ev: MotionEvent): Boolean {var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {clearStatus()
// add isDisallowInterceptif (!isDisallowIntercept && onIntercept(ev)) {isSelfNeedEvent = truehandled = onTouch(ev)} else {handled = child.dispatch(ev)if (handled) isChildNeedEvent = true
if (!handled) {handled = onTouch(ev)if (handled) isSelfNeedEvent = true}}} else {if (isSelfNeedEvent) {handled = onTouch(ev)} else if (isChildNeedEvent) {// add isDisallowInterceptif (!isDisallowIntercept && onIntercept(ev)) {isSelfNeedEvent = true
// add cancelval cancel = MotionEvent.obtain(ev)cancel.action = MotionEvent.ACTION_CANCELhandled = child.dispatch(cancel)cancel.recycle()} else {handled = child.dispatch(ev)}}}
if (ev.actionMasked == MotionEvent.ACTION_UP|| ev.actionMasked == MotionEvent.ACTION_CANCEL) {clearStatus()}
return handled}
private fun clearStatus() {isChildNeedEvent = falseisSelfNeedEvent = falseisDisallowIntercept = false}
override fun onTouch(ev: MotionEvent): Boolean {return false}
open fun onIntercept(ev: MotionEvent): Boolean {return false}
override fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean) {this.isDisallowIntercept = isDisallowInterceptparent?.requestDisallowInterceptTouchEvent(isDisallowIntercept)}}
这次改动主要是增加了发出CANCEL
事件和requestDisallowInterceptTouchEvent
机制
在发出
CANCEL
事件时有一个细节:没有在给child
分发CANCEL
事件的同时继续把原事件分发给自己的onTouch
2. 这是源码中的写法,不是我故意的,可能是为了让一个事件也只能有一个 View 处理,避免出现 bug实现
requestDisallowInterceptTouchEvent
机制时,增加了ViewParent
接口不使用这种写法也行,但使用它从代码整洁的角度看会更优雅,比如避免反向依赖,而且这也是源码的写法,于是直接搬来了
虽然目前整个框架的代码有点复杂,但对于使用者来说,依然非常简单,只是在上一版框架的基础上增加了:
如果 View 判断自己要消费事件,而且执行的是不希望被父 View 打断的操作时,需要立刻调用父 View 的
requestDisallowInterceptTouchEvent()
方法如果在
onTouch
方法中对事件消费并且做了一些操作,需要注意在收到CANCEL
事件时,对操作进行取消
到这里,事件分发的主要逻辑已经讲清楚了,不过还差一段 Activity 中的处理,其实它做的事情类似 ViewGroup,只有这几个区别:
不会对事件进行拦截
只要有子 View 没有处理的事件,它都会交给自己的
onTouch()
所以不多讲了,直接补上 Activity 的麻雀:
open class MActivity(private val childGroup: MViewGroup) {private var isChildNeedEvent = falseprivate var isSelfNeedEvent = false
open fun dispatch(ev: MotionEvent): Boolean {var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {clearStatus()
handled = childGroup.dispatch(ev)if (handled) isChildNeedEvent = true
if (!handled) {handled = onTouch(ev)if (handled) isSelfNeedEvent = true}} else {if (isSelfNeedEvent) {handled = onTouch(ev)} else if (isChildNeedEvent) {handled = childGroup.dispatch(ev)}
if (!handled) handled = onTouch(ev)}
if (ev.actionMasked == MotionEvent.ACTION_UP|| ev.actionMasked == MotionEvent.ACTION_CANCEL) {clearStatus()}
return handled}
private fun clearStatus() {isChildNeedEvent = falseisSelfNeedEvent = false}
open fun onTouch(ev: MotionEvent): Boolean {return false}}
1.6. 总结
到这里,我们终于造好了一个粗糙但不劣质的轮子,源码的主要逻辑与它的区别不大,具体区别大概有:TouchTarget
机制、多点触控机制、NestedScrolling 机制、处理各种 listener、结合 View 的状态进行处理等,相比主要逻辑,它们就没有那么重要了,大家可以自行阅读源码,之后有空也会写关于多点触控和TouchTarget
的内容 ~(挖坑预警)~
轮子的完整代码可以在在这里查看(Java版本) 这个轮子把源码中与事件分发相关的内容剥离了出来,能看到:
相比源码,这份代码足够短足够简单,那些跟事件分发无关的东西通通不要来干扰我
长度总共不超过 150 行,剔除了所有跟事件分发无关的代码,并且把一些因为其他细节导致写得比较复杂的逻辑,用更简单直接的方式表达了
相比那段经典的事件分发伪代码(见附录),这份代码又足够详细,详细到能告诉你所有你需要知道的事件分发的具体细节
那段经典伪代码只能起到提纲挈领的作用,而这份麻雀代码虽然极其精简但它五脏俱全,全到可以直接跑 —— 你可以用它进行为伪布局,然后触发触摸事件
但轮子不是最重要的,最重要的是整个演化的过程。
所以回头看,你会发现事件分发其实很简单,它的关键不在于「不同的事件类型、不同的 View 种类、不同的回调方法、方法不同的返回值」对事件分发是怎么影响的。 关键在于 「它要实现什么功能?对实现效果有什么要求?使用了什么解决方案?」,从这个角度,就能清晰而且简单地把事件分发整个流程梳理清楚。
事件分发要实现的功能是:对用户的触摸操作进行反馈,使之符合用户的直觉。
从用户的直觉出发能得到这么两个要求:
用户的一次操作只有一个 View 去消费
让消费事件的 View 跟用户的意图一致
第二个要求是最难实现的,如果有多个 View 都可以消费触摸事件,怎么判定哪个 View 更适合消费,并且把事件交给它。 我们使用了一套简单但有效的先到先得策略,让内外的可消费事件的 View 拥有近乎平等的竞争消费者的资格:它们都能接收到事件,并在自己判定应该消费事件的时候去发起竞争申请,申请成功后事件就全部由它消费。
(转载请注明作者:RubiTree,地址:blog.rubitree.com )
2. 测试轮子
可能有人会问,听你纸上谈兵了半天,你讲的真的跟源码一样吗,这要是不对我不是亏大了。 问的好,所以接下来我会使用一个测试事件分发的日志测试框架对这个小麻雀进行简单的测试,还会有实践部分真刀真枪地把上面讲过的东西练起来。
2.1. 测试框架
测试的思路是通过在每个事件分发的钩子中打印日志来跟踪事件分发的过程。 于是就需要在不同的 View 层级的不同钩子中,针对不同的触摸事件进行不同的操作,以制造各种事件分发的场景。
为了减少重复代码简单搭建了一个测试框架(所有代码都能在此处查看),包括一个可以代理 View 中这些的操作的接口IDispatchDelegate
及其实现类,和一个DispatchConfig
统一进行不同的场景的配置。 之后创建了使用统一配置和代理操作的 真实控件们SystemViews
和 我们自己实现的麻雀控件们SparrowViews
。
在DispatchConfig
中配置好事件分发的策略后,直接启动SystemViews
中的DelegatedActivity
,进行触摸,使用关键字TouchDojo
过滤,就能得到事件分发的跟踪日志。 同时,运行SparrowActivityTest
中的dispatch()
测试方法,也能得到麻雀控件的事件分发跟踪日志。
2.2. 测试过程
场景一
先配置策略,模拟View
和ViewGroup
都不消费事件的场景:
fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {return DispatchDelegate(layer)}
fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {return DispatchDelegate(layer)}
fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {retur
n DispatchDelegate(layer)}
能看到打印的事件分发跟踪日志:
[down]|layer:SActivity |on:Dispatch_BE |type:down|layer:SViewGroup |on:Dispatch_BE |type:down|layer:SViewGroup |on:Intercept_BE |type:down|layer:SViewGroup |on:Intercept_AF |result(super):false |type:down|layer:SView |on:Dispatch_BE |type:down|layer:SView |on:Touch_BE |type:down|layer:SView |on:Touch_AF |result(super):false |type:down|layer:SView |on:Dispatch_AF |result(super):false |type:down|layer:SViewGroup |on:Touch_BE |type:down|layer:SViewGroup |on:Touch_AF |result(super):false |type:down|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:down|layer:SActivity |on:Touch_BE |type:down|layer:SActivity |on:Touch_AF |result(super):false |type:down|layer:SActivity |on:Dispatch_AF |result(super):false |type:down
[move]|layer:SActivity |on:Dispatch_BE |type:move|layer:SActivity |on:Touch_BE |type:move|layer:SActivity |on:Touch_AF |result(super):false |type:move|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
[move]...
[up]|layer:SActivity |on:Dispatch_BE |type:up|layer:SActivity |on:Touch_BE |type:up|layer:SActivity |on:Touch_AF |result(super):false |type:up|layer:SActivity |on:Dispatch_AF |result(super):false |type:up
因为系统控件和麻雀控件打印的日志一模一样,所以只贴出一份
这里用
BE
代表before
,表示该方法开始处理事件的时候,用AF
代表after
,表示该方法结束处理事件的时候,并且打印处理的结果从日志中能清楚看到,当
View
和ViewGroup
都不消费DOWN
事件时,后续事件将不再传递给View
和ViewGroup
场景二
再配置策略,模拟View
和ViewGroup
都消费事件,同时ViewGroup
在第二个MOVE
事件时认为自己需要拦截事件的场景:
fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {return DispatchDelegate(layer)}
fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {return DispatchDelegate(layer,ALL_SUPER,// 表示 onInterceptTouchEvent 方法中,DOWN 事件返回 false,第一个 MOVE 事件返回 false,第二个第三个 MOVE 事件返回 trueEventsReturnStrategy(T_FALSE, arrayOf(T_FALSE, T_TRUE, T_TRUE), T_SUPER),ALL_TRUE)}
fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {return DispatchDelegate(layer, ALL_SUPER, ALL_SUPER, ALL_TRUE)}
能看到打印的事件分发跟踪日志:
[down]|layer:SActivity |on:Dispatch_BE |type:down|layer:SViewGroup |on:Dispatch_BE |type:down|layer:SViewGroup |on:Intercept |result(false):false |type:down|layer:SView |on:Dispatch_BE |type:down|layer:SView |on:Touch |result(true):true |type:down|layer:SView |on:Dispatch_AF |result(super):true |type:down|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:down|layer:SActivity |on:Dispatch_AF |result(super):true |type:down
[move]|layer:SActivity |on:Dispatch_BE |type:move|layer:SViewGroup |on:Dispatch_BE |type:move|layer:SViewGroup |on:Intercept |result(false):false |type:move|layer:SView |on:Dispatch_BE |type:move|layer:SView |on:Touch |result(true):true |type:move|layer:SView |on:Dispatch_AF |result(super):true |type:move|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
[move]|layer:SActivity |on:Dispatch_BE |type:move|layer:SViewGroup |on:Dispatch_BE |type:move|layer:SViewGroup |on:Intercept |result(true):true |type:move|layer:SView |on:Dispatch_BE |type:cancel|layer:SView |on:Touch_BE |type:cancel|layer:SView |on:Touch_AF |result(super):false |type:cancel|layer:SView |on:Dispatch_AF |result(super):false |type:cancel|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:move|layer:SActivity |on:Touch_BE |type:move|layer:SActivity |on:Touch_AF |result(super):false |type:move|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
评论