View 的 事件分发体系 及 滑动冲突解决方案
Android 中 当遇到滑动冲突的问题,就会涉及到事件的分发与响应。事件分发,事件拦截,事件处理 是 理清各个 view 所要处理的事件的三个重要的方法。这里就结合源码分析一下 从点击 Activity 上的一个控件开始,到事件响应结束的 事件传递的整个流程。包括 Activity 对事件的处理,ViewGroup 对事件的处理,View 对事件的处理。
声明:本文参考自 《Android开发艺术探索》,非常赞的一本书,有兴趣可以去看一看。
View 的事件分发体系
先来看一张 事件分发 的流程图(搜刮自网络)
图里表现的非常清楚,当 view 产生一个 touch 事件后,如果 该 view 是一个 ViewGroup, 则去判断 ViewGroup 里面的 onInterceptTouchEvent() 方法,表示是否拦截事件,默认是返回 false,表示不拦截,让事件传递给 子view,子view 如果还是 ViewGroup,默认仍然继续向下传递,直到 子 view 不是 ViewGroup,则直接调用 子view 的 onTouchEvent() 方法,return true 表示消费了此事件,传递过程结束,false 则将事件向上(ViewGroup)传,都不处理的话,最终会传递到 Activity,此时,Activity 的 onTouchEvent() 将被调用。
如果我们中途重写了 ViewGroup 的 onInterceptTouchEvent() 方法,让其返回 true,则表示 我们这个 ViewGroup 拦截了这个事件,在 onTouchEvent() 方法中可以处理,不处理则与上面向上传递的流程一样。
当然,图中只是比较普遍的一种事件传递流程,实际上具体的细节与图还不尽相同,比如 ViewGroup 的 onInterceptTouchEvent() 方法并不是一定会执行的,具体的本篇文章会详细分析。
Activity 对事件的分发
点击事件产生时,最先传递给当前的 Activity,由 Activity 的 dispatchTouchEvent() 来进行事件派发,来看代码:
1 | public boolean dispatchTouchEvent(MotionEvent ev) { |
onUserInteraction() 是 Activity 通过 Window 向 view 分发事件之前 调用的方法,我们一般可以重写该方法来管理状态栏的通知。
重点在 getWindow().superDispatchTouchEvent(ev) 这个方法,这里就是开始调用 Window 的 事件分发的方法,(如果此方法消费了事件,那么 Activity 也就消费事件,不再传递,循环结束),我们跟进去看一下:
1 | public abstract boolean superDispatchTouchEvent(MotionEvent event); |
Window 类的 superDispatchTouchEvent 是一个抽象方法,在它源码的文档中,可以看到一段注释:
1 | /** |
Window 类可以控制顶级 view 的外观和行为策略,它的唯一实现类是 android.policy.PhoneWindow. 这个类是在 FrameWork 层里面,具体路径为:
**frameworks/policies/base/phone/com/android/internal/policy/impl/PhoneWindow.java **,在线查看
来看一下它是如何分发事件的:
1 |
|
这里有一个 mDecor,它是一个 DecorView,是 PhoneWindow 的一个内部类,我们在 Activity 里面 使用 getWindow().getDecorView() 获取到的 view 就是这个 DecorView,可以看到,Window 在获取到这个 decorView 时,把 TitleView 单拎出来出来封装好了,我们使用的 setContentView(View v),就是把自己的布局 塞到 这个 decorView 当中。
好,现在事件传递到了 DecorView 当中,DecorView 继承自 FrameLayout,事件 FrameLayout 继承自 ViewGroup,最终调用的是 ViewGroup 的事件分发的方法。下面就分析 ViewGroup 的事件分发方法。
ViewGroup 对事件的分发
ViewGroup 的 onInterceptTouchEvent() 永远返回 false,意味着对事件永远不拦截,这也是很好理解的,不然 子view 就不会响应事件了 。
1 | public boolean onInterceptTouchEvent(MotionEvent ev) { |
ViewGroup 的 dispatchTouchEvent() 方法很长,我们一点一点分析。
1 | if (actionMasked == MotionEvent.ACTION_DOWN) { |
这里进行了初始化 down 事件,在 ACTION_DOWN 事件到来时,会清除以往的 Touch 状态,cancelAndClearTouchTargets() 方法里将 mFirstTouchTarget 设置为 null,resetTouchState() 方法里重置了 touch 状态标识。接着往下看。
1 | // Check for interception. |
intercepted 这个变量判断是否拦截,当事件是 ACTION_DOWN 或者 mFirstTouchTarget != null 时,ViewGroup 正常情况下是不拦截的,( mFirstTouchTarget != null 从后面的逻辑可以知道,它是表示 ViewGroup 没有拦截 Touch 事件并且 将事件交给了 子View 消费了 )。
但是这里有一个情况,就是 FLAG_DISALLOW_INTERCEPT 这个标记位,如果 ViewGroup 的子类 调用 getParent().requestDisallowInterceptTouchEvent(boolean disallowIntercept), 那么就可以改变这个标记位的值,具体有两种情况:
如果参数 disallowIntercept 值为 true,表示禁止 ViewGroup 拦截,那么 intercepted = false;
如果参数 disallowIntercept 值为 false,那么 intercepted = onInterceptTouchEvent(ev),就可以根据 onInterceptTouchEvent() 的返回值来设置是否 禁止 ViewGroup 对事件的拦截,默认是 false,我们可以修改这个值来拦截事件。
有一点注意:getParent().requestDisallowInterceptTouchEvent() 方法不会影响 ViewGroup 对 ACTION_DOWN 事件的处理,只能拦截 ACTION_MOVE 和 ACTION_UP 事件,前面已经提过,ViewGroup 事件分发一开始就在 ACTION_DOWN 时重置了 Touch 状态标识,即 FLAG_DISALLOW_INTERCEPT。
如果事件不是 ACTION_DOWN 并且 mFirstTouchTarget == null,那么直接将 intercepted == true,表示 ViewGroup 拦截 Touch 事件,直白地说:如果 ACTION_DOWN 没有被 子View 消费, 那么当 ACTION_MOVE 和 ACTION_UP 到来时 ViewGroup 不再去调用 onInterceptTouchEvent() 判断是否需要拦截而是直接的将 intercepted == true 表示由其自身处理 Touch 事件。
这部分 FLAG_DISALLOW_INTERCEPT 这个标识位可以对一些 滑动冲突 的问题 提供了一个解决思路。
接着看 ViewGroup 不拦截事件的时候,分发事件给 子view 的过程。
1 | final View[] children = mChildren; |
这里首先遍历 ViewGroup 的所有 子view,判断 子view 是否能够接受到点击事件(主要看两点:1. 子view 是否在播放动画 2.点击事件的坐标是否在 子view 的区域内),如果满足其中一个,那么事件就会传递给这个 子view 来处理,上面的 dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign) 方法
实际上就是调用的 子view 的 dispatchTouchEvent() 方法,可以看下该方法内部的一段代码:
1 | if (child == null) { |
因为上面 child 传递的不是 null,所以会直接调用 子view 的 dispatchTouchEvent() 方法,这样事件就交给 子view 处理了,从而完成了一轮事件分发。
如果 子view 的 dispatchTouchEvent() 返回 false,ViewGroup 就会继续遍历,将事件发给下一个 子 view。
如果 子view 的 dispatchTouchEvent() 返回 true,这里就会跳出循环,终止遍历,跳出之前,还做了一些事情,来看一下:
1 | newTouchTarget = addTouchTarget(child, idBitsToAssign); |
这里,将 mFirstTouchTarget 进行了赋值,它是一种单链表结构,随后 alreadyDispatchedToNewTouchTarget 置为 true,表示已经将 Touch 事件分发到了 子View,并且 子View 消费掉了 Touch 事件,前面已经有分析,mFirstTouchTarget 是否为空,直接影响到 ViewGroup 对事件拦截的策略。
好,如果遍历所有的 子view 后 mFirstTouchTarget 仍然为 null,这里就包含两种情况:第一种 ViewGroup 没有 子View;第二种是 子View 虽然处理了点击事件,但是在 dispatchTouchEvent() 方法中 返回了 false (一般是因为 子View 在 onTouchEvent 里面返回来 false)。这两种情况,看 ViewGroup 是如何处理的:
1 | // Dispatch to touch targets. |
这里 第三个参数 child 传的是 null,所以会调用 super.dispatchTouchEvent(event) 方法,这里就转到了 View 的 dispatchTouchEvent() 方法中去了,下面接着看 View 的事件传递过程。
View 对 事件的处理过程
1 | public boolean dispatchTouchEvent(MotionEvent event) { |
View 的 dispatchTouchEvent() 方法比较简单,它不能 向下继续分发事件, 也没有拦截事件的方法,所以只能自己处理事件。这里 首先判断 有没有设置 mOnTouchListener,如果有,在判断 mOnTouchListener.onTouch() 方法有没有返回 true,如果我们在外面设置了 onTouch() 方法 返回了 true,那么 事件就此消费,不会再执行 onTouchEvent() 方法。如果没有,我们接着看 onTouchEvent() 方法。
1 | if ((viewFlags & ENABLED_MASK) == DISABLED) { |
这里判断了 View 处于不可用状态下的处理过程,不可用状态下,仍然可以消耗点击事件,只要 View 是 clickable 或者 longClickable 的。
在看这一段代码:
1 | if (((viewFlags & CLICKABLE) == CLICKABLE || |
View 是 clickable 或者 longClickable 的状态下,会触发 performClick() 方法,该方法如下:
1 | public boolean performClick() { |
这里如果 mOnClickListener 不为 null,会调用它的 onClick 方法。View 的 LONG_CLICKABLE 属性默认为 false,CLICKABLE 属性和 View 有关,可点击的 View 比如 Button,其 CLICKABLE 属性为 true,不可点击的 View 比如 TextView,其属性为 false。通过 setOnClickListener 会自动将 View 的 CLICKABLE 设为 true,setOnLongClickListerner 会自动将 View 的 LONG_CLICKABLE 设为 true。
1 | public void setOnClickListener(OnClickListener l) { |
到这里,事件分发的源码重要的部分都已经分析完了,下面在总结一些规律性的东西帮助记忆。
总结
- 一个点击事件产生后,它的传递过程如下: Activity -> Window -> View。顶级 View 接收到事件之后,就会按相应规则去分发事件。如果一个 View 的 onTouchEvent 方法返回 false,那么将会交给父容器的 onTouchEvent 方法进行处理,逐级往上,如果所有的 View 都不处理该事件,则交由 Activity 的 onTouchEvent 进行处理。
- ViewGroup 默认不拦截任何事件。
- 子View 可以通过调用 getParent().requestDisallowInterceptTouchEvent(true); 阻止 ViewGroup 对其 MOVE 或者 UP 事件进行拦截
- 如果某一个 View 开始处理事件,如果他不消耗 ACTION_DOWN 事件(也就是 onTouchEvent 返回 false),则同一事件序列比如接下来进行 ACTION_MOVE,ACTION_UP 都不会再交给该 View 处理,而是将事件交由它的父容器 onTouchEvent 方法 去处理。
- 如果某一个 View 开始处理事件,如果他不消耗 除 ACTION_DOWN 以外的事件,那么这个事件会消失,此时 父容器的 onTouchEvent 并不会调用,并且当前 view 可以持续收到后续的事件,最终这些消失的事件会传递给 Activity 处理。
- TextView、ImageView 这些不作为容器的 View,一旦接受到事件,就调用 onTouchEvent 方法,它们本身没有 onInterceptTouchEvent 方法。正常情况下,它们都会消耗事件(返回 true),除非它们是不可点击的(clickable 和 longClickable 都为 false)。
- View 的 enable 属性不影响 onTouchEvent 的返回值。哪怕一个 view 是 disable 的,只要 clickable 和 longClickable 有一个为 true,onTouchEvent 就返回 true。
- 点击事件分发过程如下 dispatchTouchEvent —> OnTouchListener 的 onTouch 方法 —> onTouchEvent -> OnClickListener 的 onClick 方法。也就是说,我们平时调用的 setOnClickListener,优先级是最低的,所以,onTouchEvent 或 OnTouchListener 的 onTouch 方法如果返回 true,则不响应 onClick 方法。
View 的滑动冲突
View 滑动冲突的解决有固定的套路,常见的冲突可以简单归为三种:1. 外部和内部滑动方向不一致 2. 外部和内部滑动方向一致 3. 前两种的嵌套。 基于对上面 View 的事件分发体系的理解, View 的滑动冲突就相对简单了。处理滑动冲突的思路主要有两种:外部拦截 和 内部拦截。
外部拦截
判断滑动的特征,如果水平滑动距离 > 竖直滑动距离,则为水平滑动,反之为竖直滑动。假设外部 View 可以水平滑动,内部 View 可以竖直滑动,那么在外部 View 的 onInterceptTouchEvent 方法判断,如果触摸事件为竖直滑动,则应该放行,也就是返回 false,然后交给内部 View 来处理,使内部 子View 就可以实现竖直滑动;如果触摸事件为水平滑动,外部 view 则应该拦截,交由自己处理。
外部拦截的伪代码:
1 |
|
内部拦截
外部 View 不拦截,交给内部 View 处理,如果内部 View 有需要就自己消耗掉,否则交给上一层,这样违反了事件分发机制,所以需配合 requestDisallowInterceptTouchEvent 方法进行处理。
内部拦截的伪代码:
1 |
|
非常棒的文章
Title: View 的 事件分发体系 及 滑动冲突解决方案
Author: mjd507
Date: 2016-12-20
Last Update: 2024-01-27
Blog Link: https://mjd507.github.io/2016/12/20/View-MotionEvent-dispatch-And-Sliding-Conflict/
Copyright Declaration: This station is mainly used to sort out incomprehensible knowledge. I have not fully mastered most of the content. Please refer carefully.