最顶层View 直接持有最下层某个View 的引用合理吗?答案是否定的。首先,这导致View 层级依赖之间的混乱;其次,顶层View 本身持有了最下层某个View 的引用,则这之间若干个层级的View 的target 属性都毫无意义。
更能将树结构应用淋漓尽致的方式是构建一个链表:
每个View 节点都持有事件的下一级消费者,当同一事件序列后续的触摸事件抵达时,不再需要进行消耗性能的DFS 算法,而是直接交给下一级的子View ,子View 则直接交给下下一级的子View ,直到事件到达真正的消费者:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fTaztKRK-1631094714932)(https://user-gold-cdn.xitu.io/2020/4/15/1717e7894ecfcaf0?imageView2/0/w/1280/h/960/ignore-error/1)]
和链表的定义类似,设计者设计了TouchTarget 类,同时为每一个ViewGroup 都声明这样一个成员,作为链表的一个结点,以描述当前事件序列的传递方向:
public abstract class ViewGroup extends View {
// 链表的下一级结点
private TouchTarget mFirstTouchTarget;
private static final class TouchTarget {
// 描述接下来的触摸事件由哪一个子View接收并分发
public View child;
}
}
那么这个链表是怎么构建的呢?正如上文所说,当接收到一个ACTION_DOWN 时,意味着一次完整事件序列的开始,通过递归遍历找到真正对事件进行消费的Child
读者需认真揣摩 事件序列 的相关概念,因为这个知识点贯穿了整个 事件分发机制 流程,可以说是非常核心的知识点;同时,掌握它也是下文快速掌握 事件拦截机制 的关键。
事件拦截机制
大多数Android 开发者对 事件拦截机制 都不会陌生,读者应该都有了解,ViewGroup 层级额外设计了onInterceptTouchEvent() 函数并向外暴露给开发者,以达到让ViewGroup 不再将触摸事件交给View 处理,而是自身决定是否消费事件,并将结果反馈给上层级的ViewGroup 。
1、缘由
为什么设计出这样一种拦截机制?其实这是有必要的,以常规的ScrollView 对应的滑动页面为例,当用户抛出了一个列表的滑动操作,这时,对应的触摸事件序列是否还有必要交给ScrollView 的子View 进行处理?
答案是否定的,当ScrollView 接收到滑动操作时,理所当然,本次滑动操作相关事件都不再需要交给子View ,而是直接交给ScrollView 去处理滑动操作。
读者同样需要明白,并非所有事件序列都会被拦截——当用户点击ScrollView 中的某个按钮时,设计者又期望这次的点击操对应的系列事件能够被ScrollView 分发给子Button 去处理,这样开发者最终能够在按钮本身的OnClickListener 中观察到这次点击事件,并进行对应的业务操作。
因此,对于不同类型的ViewGroup ,开发者需要在不同的场景下,做出是否拦截事件的决定,这种 父控件根据本身职责去拦截指定场景的事件序列 的行为,我们称之为 事件拦截机制。
2、拦截函数:onInterceptTouchEvent()
那么开发者如何做,才能保证 不同场景的事件被合理的向下分发或直接拦截 呢?设计者据此提供了 onInterceptTouchEvent() 拦截函数:
public abstract class ViewGroup extends View {
public boolean onInterceptTouchEvent(MotionEvent ev) {
// ...
return false;
}
}
其定义是,当触摸事件到来时,事件首先作为参数传入onInterceptTouchEvent 函数中,开发者自定义onInterceptTouchEvent 内部逻辑,以决定是否对该事件进行拦截,并将boolean 类型的结果进行返回。当返回值为true 时,该事件序列接下来所有的事件都会被当前的ViewGroup 拦截;通常情况下,ViewGroup 的该函数默认返回false ,即不对事件进行拦截。
以上文为例,我们可以对ScrollView 添加类似如下策略——当用户发起一个 点击事件 的操作时,onInterceptTouchEvent 返回false ,将事件交给下游的子控件去决定消费与否;而当用户 滑动屏幕 时,则将事件序列进行拦截:
public class ScrollView extends ViewGroup {
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 这里模拟一个抽象的函数代替实际的业务逻辑
// 实际源码中,这里是根据对触摸事件序列的复杂判断,得出操作是否是滑动事件
if (isUserScrollAction(ev)) {
return true;
} else {
return false;
}
}
}
事件序列 在这个过程中再次起到了 至关重要 的作用。针对单独一个触摸事件——例如 ACTION_DOWN 或ACTION_MOVE 而言,我们都无法确定这是否是我们希望拦截的操作。而当我们获取到 事件序列 中连续若干个事件后,我们则可以根据手势操作的方向和距离(判断是否是滑动)、触摸屏幕的时间(判断是点击事件还是长按事件)对用户的这次行为进行定义,最终决定是否进行拦截。
——这意味着,当ScrollView 接收到最初的ACTION_DOWN 事件时,父控件并没有立即对事件进行拦截,而是交给了子Button 去消费;而当接收了若干个ACTION_MOVE 事件时,ScrollView 的onInterceptTouchEvent() 函数中判断得出 本次触摸行为方向朝下,是滑动事件,然后该函数返回true ,导致本次和接下来的触摸事件都会被拦截。
等等!到了这里,读者似乎推断出了一个怪异的结论: 针对一个完整事件序列的向下分发过程而言,触摸事件的消费者并不一定只有一个角色——这似乎不太符合直觉。
但事实的确如此。
既然一个完整的 事件序列 其事件可能会交给不同的角色,这是否意味着极端情况下,用户的一次 滑动行为 不但会触发了父控件本身的 滑动 效果,用户也会同时接收到Button 子控件的 点击 效果?
目前为止的设计中确实存在这个缺陷,因此接下来我们需要增加新的逻辑单元去弥补这个问题,ACTION_CANCEL 闪亮登场。
3、ACTION_CANCEL:弥补与终结
终于来到了ACTION_CANCEL 的舞台,报幕员对这名演员的介绍是两个单词:弥补 和 终结。
现在我们希望,当Button 的父控件ScrollView 对滑动操作进行了拦截时,Button 的点击事件不再会被响应。
正常的逻辑处理中,Button 需要在接收到ACTION_UP 时,判断整个事件序列持续的时间,如果符合一系列单击操作的前置定义(比如touchable = true 或clickable = true 等等),就直接交给单击事件的监听器View.OnClickListener 去处理。
我们可以将ACTION_UP 视为 事件序列 中的 终止事件,但很明显,这个逻辑在 事件拦截机制 中并不适用,因为当父控件对事件进行了拦截后,接下来整个序列中所有的事件都转交给了父控件,子控件再也接受不到任何事件,包括ACTION_UP 。
我们总是希望有始有终(比如期待面试结果的及时反馈),事件分发机制 中也是一样,当子控件事件被父控件拦截,子控件也需要一个 终止事件 的通知以作出对应的行为。
因此,设计者额外提供了ACTION_CANCEL 事件,以通知当前的View 作出对 事件被拦截 之后的收尾工作,比如取消点击事件或长按事件相关判断逻辑中的计时器(如果有的话,下同),或者对当前控件滑动距离计算的重置等等,避免了「既发生父控件滑动」又「触发子控件点击」的尴尬场景。
现在,当父控件拦截了触摸事件后,子控件立即接收到一个额外的ACTION_CANCEL 作为弥补,并草草进行了相关的收尾工作,之后的业务逻辑则统统交给了父控件去处理。
事件拦截机制 到此似乎告一段落,读者认真思考,这样的逻辑处理目前已经是完美的了么?
拦截机制与反拦截机制
父控件:「我无效你的效果。」 子控件:「我无效你的无效。」
1、压迫与抗争
音乐播放器的进度条控件SeekBar 提出了严重抗议。
当SeekBar 与ScrollView 搭配使用时,前者愕然发现,作为子控件,其最引以为豪的技能——滑动调整音频进度的功能完全被废掉了。
这是当然的,ScrollView 接收到滑动事件时,会很自然的将接下来相关的所有事件都进行拦截,而作为子控件的SeekBar 连汤都喝不上。
这是不合理的设计,父控件的权利实在太大了,而子控件对此完全束手无策。因此设计者为ViewGroup 设计了一个另外一个API ——requestDisallowInterceptTouchEvent(boolean) 。
该函数的作用是,命令指定的ViewGroup 是否 不再针对事件序列进行拦截 ,而是正常将事件交给子控件去处理是否消费事件。
以SeekBar 为例,其完全可以这样设计:
public abstract class AbsSeekBar extends ProgressBar {
// ...代码大幅简化,具体逻辑请参考源码...
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
// 当接收到ACTION_DOWN事件时,命令父控件不能拦截事件序列
case MotionEvent.ACTION_DOWN:
mParent.requestDisallowInterceptTouchEvent(true);
break;
// ...
}
}
}
现在,即使ScrollView 内部持有对 滑动操作 相关的拦截机制,但SeekBar 依然可以通过更高等级的API 对其进行压制,从而跳过父控件相关的拦截,并自己消费滑动事件——最终用户得到了他希望得到的操作体验(滑动调节播放进度)。
2、更深入性思考
上一小节的叙述本身是存在瑕疵的,通常来说,调节进度的SeekBar 处理的是横向滑动,而ScrollView 处理的则是竖向滑动,本质上两者逻辑并不冲突。
这样描述,只是为了让读者能够更容易的理解 反拦截机制 对应的requestDisallowInterceptTouchEvent() 函数设计的目的及意义,对此读者不必深究——当然,读者也可自定义实现一个横向滑动的HorizontalScrollView ,以得到上一小节中滑动冲突的效果,本文不赘述。
另外一点需要思考的是,当子控件调用了父控件的requestDisallowInterceptTouchEvent(true) 函数无效化了父控件的拦截机制之后,父控件拦截机制的无效化需要一直存在吗 ?
答案是否定的,正确的方式是应该在某个时间点 对父控件拦截机制进行重启——即调用requestDisallowInterceptTouchEvent(false) ,这样才能保证在触摸到其它子控件时,父控件依然能够对 事件拦截机制 进行正常的运转。
那么这个重置的时间点如何把握,在子控件接收到ACTION_UP 时调用吗?
在子控件 事件序列的终止事件中重置状态,这听起来不错,但是需要注意的是,拦截机制被无效化的状态是存在父控件ViewGroup 中的,因此换个思路,更好的时机会不会其实是隐藏在ViewGroup 中的呢?
3、更好的时机
设计者最终将重置的时机放在了父控件 事件序列的起始事件——ACTION_DOWN 的处理逻辑中。
public abstract class ViewGroup extends View {
@Override
**最后送福利了,现在关注我可以获取包含源码解析,自定义View,动画实现,架构分享等。
内容难度适中,篇幅精炼,每天只需花上十几分钟阅读即可。
大家可以跟我一起探讨,有flutter—底层开发—性能优化—移动架构—资深UI工程师 —NDK相关专业人员和视频教学资料,还有更多面试题等你来拿**
**[CodeChina开源项目地址:https://codechina.csdn.net/m0_60958482/android_p7](https://codechina.csdn.net/m0_60958482/android_p7)**
![录播视频图.png](https://img-blog.csdnimg.cn/img_convert/91a9df01bb5efb9ed7f00b0023661ab6.png)
你来拿**
**[CodeChina开源项目地址:https://codechina.csdn.net/m0_60958482/android_p7](https://codechina.csdn.net/m0_60958482/android_p7)**
[外链图片转存中...(img-Ss2UUwhJ-1631094714933)]
|