18.Android事件拦截
目录介绍
- 01.事件体系的介绍
- 1.1 事件体系说明
- 1.2 什么是事件序列
- 1.3 如何理解事件序列
- 1.4 为什么会有分发
- 02.事件序列的设计
- 2.1 事件序列设计思路
- 2.2 事件序列导图
- 2.3 理解事件中递归设计
- 03.事件传递流程
- 3.1 事件传递的全流程
- 3.2 事件分发机制方法
- 3.3 事件分发流程
- 3.4 事件拦截流程
- 3.5 事件处理流程
- 04.事件体系案例分析
- 4.1 先看一个案例
- 4.2 该案例事件传递情况
- 4.3 案例默认处理分析
- 4.4 案例处理事件分析
- 4.5 拦截DOWN事件
- 4.6 拦截后续事件(MOVE、UP)
- 05.事件机制源码分析
- 5.1 Activity事件分发机制
- 5.2 ViewGroup事件分发机制
- 5.3 View事件分发机制
- 06.事件冲突案例分析
- 6.1 滑动冲突说明
- 6.2 外部拦截法
- 6.3 内部处理法
- 6.4 一个常见案例分析
- 6.5 选择合适的方案
01.事件体系的介绍
1.1 事件体系说明
- 完整的掌握
Android
事件分发体系并非易事- 其整个流程涉及到了 系统启动流程(
SystemServer
)、输入管理(InputManager
)、系统服务和UI的通信(ViewRootImpl
+Window
+WindowManagerService
)、View
层级的 事件分发机制 等等一系列的环节。
- 其整个流程涉及到了 系统启动流程(
- 事件拦截机制
- 是基于
View
层级 事件分发机制 的一个进阶性的知识点。
- 是基于
1.2 什么是事件序列
- 什么是事件序列?可以将其理解为 用户一次完整的触摸操作流程。
- 举例来说,用户单击按钮、用户滑动屏幕、用户长按屏幕中某个UI元素等等,都属于该范畴。
- 每一次我们触摸屏幕,都会产生一连串的触摸事件(
MotionEvent
),这些一连串的触摸事件合起来就是一个触摸事件序列。
1.3 如何理解事件序列
- 事件分发的本质原理就是递归,对此简单的实现方式是:
- 每接收一个新的事件,都需要进行一次递归才能找到对应消费事件的View,并依次向上返回事件分发的结果。
- 思考一下
- 以每个触摸事件作为最基本的单元,都对
View
树进行一次遍历递归?这对性能的影响显而易见,因此这种设计是有改进空间的。
- 以每个触摸事件作为最基本的单元,都对
- 将 事件序列 作为最基本的单元进行处理则更为合适。
- 首先,设计者根据用户的行为对MotionEvent中添加了一个Action的属性以描述该事件的行为:DOWN,MOVE,UP,其他Action事件……
- 针对用户的一次触摸操作,必然对应了一个事件序列,从用户手指接触屏幕,到移动手指,再到抬起手指 ——单个事件序列必然包含ACTION_DOWN、ACTION_MOVE ... ACTION_MOVE、ACTION_UP 等多个事件,这其中ACTION_MOVE的数量不确定,ACTION_DOWN和ACTION_UP的数量则为1。
- 任何事件列都是以DOWN事件开始,UP事件结束,中间有无数的MOVE事件,如下图:
image
1.4 为什么会有分发
- 举一个实际案例了解为何有分发
- 假设屏幕坐标为(11,11)的区域既属于一个LinearLayout,又属于LinearLayout下的一个Button。那这次触碰所产生的触摸事件,是该给LinearLayout还是Button呢?
- 当然,最终会被Button点击所处理。那触摸事件是怎么给到Button的呢?需要经过LinearLayout吗?怎样能让Button不处理呢?在View树上传递与消费的过程,这就是事件的分发。
02.事件序列的设计
2.1 事件序列设计思路
03.事件传递流程
3.1 事件传递的全流程
- Android触摸事件流程
- 1.一个事件序列从手指触摸屏幕开始,到触摸结束。同一事件序列是以ACTION_DOWN开始,中间有数量不定的ACTION_MOVE事件,最终以ACTION_UP结束
- 2.事件传递顺序是:Activity(Window)——>ViewGroup——>View;最后顶级View接收到事件后,就会按照事件分发机制去分发事件
- 3.事件传递过程是由外向内的,即事件总是有父元素分发给子元素
3.2 事件分发机制方法
- 事件分发过程由dispatchTouchEvent() 、onInterceptTouchEvent()和onTouchEvent()三个方法协助完成,如下图:
- 下面将用一段伪代码来阐述上述三个方法的关系和点击事件传递规则
// 点击事件产生后,会直接调用dispatchTouchEvent分发方法 public boolean dispatchTouchEvent(MotionEvent ev) { //代表是否消耗事件 boolean consume = false; if (onInterceptTouchEvent(ev)) { //如果onInterceptTouchEvent()返回true则代表当前View拦截了点击事件 //则该点击事件则会交给当前View进行处理 //即调用onTouchEvent ()方法去处理点击事件 consume = onTouchEvent (ev) ; } else { //如果onInterceptTouchEvent()返回false则代表当前View不拦截点击事件 //则该点击事件则会继续传递给它的子元素 //子元素的dispatchTouchEvent()就会被调用,重复上述过程 //直到点击事件被最终处理为止 consume = child.dispatchTouchEvent (ev) ; } return consume; }
3.3 事件分发流程
- 事件分发的对象是事件
- 注意,事件分发是向下传递的,也就是父到子的顺序。
- 事件分发的本质:将点击事件(MotionEvent)向某个View进行传递并最终得到处理
- 即当一个点击事件发生后,系统需要将这个事件传递给一个具体的View去处理。这个事件传递的过程就是分发过程。
- Android事件分发机制的本质是要解决,点击事件由哪个对象发出,经过哪些对象,最终达到哪个对象并最终得到处理。
3.4 事件拦截流程
3.5 事件处理流程
04.事件体系案例分析
4.1 先看一个案例
- 讨论的布局层次如下:
- 最外层:Activity A,包含两个子View:ViewGroup B、View C
- 中间层:ViewGroup B,包含一个子View:View C
- 最内层:View C
- 触摸情况
- 假设用户首先触摸到屏幕上View C上的某个点(如图中黄色区域),那么Action_DOWN事件就在该点产生,然后用户移动手指并最后离开屏幕。
4.2 该案例事件传递情况
- 一般的事件传递场景有:
- 默认情况
- 处理事件
- 拦截DOWN事件
- 拦截后续事件(MOVE、UP)
4.3 案例默认处理分析
- 即不对控件里的方法(dispatchTouchEvent()、onTouchEvent()、onInterceptTouchEvent())进行重写或更改返回值
- 那么调用的是这3个方法的默认实现:调用父类的方法
- 事件传递情况:
- 从Activity A---->ViewGroup B--->View C,从上往下调用dispatchTouchEvent()
- 再由View C--->ViewGroup B --->Activity A,从下往上调用onTouchEvent()
- 注:虽然ViewGroup B的onInterceptTouchEvent方法对DOWN事件返回了false,后续的事件(MOVE、UP)依然会传递给它的onInterceptTouchEvent()
- 注意:这一点与onTouchEvent的行为是不一样的。
4.4 案例处理事件分析
- 假设View C希望处理这个点击事件,即C被设置成可点击的(Clickable)或者覆写了C的onTouchEvent方法返回true。
- 最常见的:设置Button按钮来响应点击事件
- 事件传递情况:
- DOWN事件被传递给C的onTouchEvent方法,该方法返回true,表示处理这个事件
- 因为C正在处理这个事件,那么DOWN事件将不再往上传递给B和A的onTouchEvent();
- 该事件列的其他事件(Move、Up)也将传递给C的onTouchEvent()
4.5 拦截DOWN事件
- 假设ViewGroup B希望处理这个点击事件
- 即B覆写了onInterceptTouchEvent()返回true、onTouchEvent()返回true。
- 事件传递情况:
- DOWN事件被传递给B的onInterceptTouchEvent()方法,该方法返回true,表示拦截这个事件,即自己处理这个事件(不再往下传递)
- 调用onTouchEvent()处理事件(DOWN事件将不再往上传递给A的onTouchEvent())
- 该事件列的其他事件(Move、Up)将直接传递给B的onTouchEvent()
- 该事件列的其他事件(Move、Up)将不会再传递给B的onInterceptTouchEvent方法,该方法一旦返回一次true,就再也不会被调用了。
4.6 拦截后续事件(MOVE、UP)
- 假设ViewGroup B没有拦截DOWN事件(还是View C来处理DOWN事件),但它拦截了接下来的MOVE事件。
- DOWN事件传递到C的onTouchEvent方法,返回了true。
- 在后续到来的MOVE事件,B的onInterceptTouchEvent方法返回true拦截该MOVE事件,但该事件并没有传递给B;这个MOVE事件将会被系统变成一个CANCEL事件传递给C的onTouchEvent方法
- 后续又来了一个MOVE事件,该MOVE事件才会直接传递给B的onTouchEvent()
1.后续事件将直接传递给B的onTouchEvent()处理 2.后续事件将不会再传递给B的onInterceptTouchEvent方法,该方法一旦返回一次true,就再也不会被调用了。
- C再也不会收到该事件列产生的后续事件。
- 特别注意:
- 如果ViewGroup A 拦截了一个半路的事件(如MOVE),这个事件将会被系统变成一个CANCEL事件并传递给之前处理该事件的子View;
- 该事件不会再传递给ViewGroup A的onTouchEvent()
- 只有再到来的事件才会传递到ViewGroup A的onTouchEvent()
05.事件机制源码分析
5.1 Activity事件分发机制
- 当一个点击事件发生时,事件最先传到Activity的dispatchTouchEvent()进行事件分发
- 具体是由Activity的Window来完成,其实是由Activity---PhoneWindow---DecorView---FrameLayout---ViewGroup
- Activity的dispatchTouchEvent()源码分析
Activity#dispatchTouchEvent(),这个是Activity处理事件分发的入口 第一步:判断MotionEvent是否是down事件,当手指按下的时候调用onUserInteraction()方法,该方法空实现(从注释得知:当此activity在栈顶时,触屏点击按home,back,menu键等都会触发此方法,用于屏保) 第二步:调用getWindow().superDispatchTouchEvent(ev),接着从PhoneWindow--->DecorView--->FrameLayout--->最终是ViewGroup的dispatchTouchEvent() 执行getWindow().superDispatchTouchEvent(ev)实际上是执行了ViewGroup.dispatchTouchEvent(event),这样事件就从 Activity 传递到了 ViewGroup
- 当一个点击事件发生时,调用顺序如下
- 1.事件最先传到Activity的dispatchTouchEvent()进行事件分发
- 2.调用Window类实现类PhoneWindow的superDispatchTouchEvent()
- 3.调用DecorView的superDispatchTouchEvent()
- 4.最终调用DecorView父类的dispatchTouchEvent(),即ViewGroup的dispatchTouchEvent()
5.2 ViewGroup事件分发机制
- ViewGroup的dispatchTouchEvent()源码分析
- ViewGroup#dispatchTouchEvent(),这个是事件分发的入口。在这个里面做了很多逻辑处理
// 发生ACTION_DOWN事件或者已经发生过ACTION_DOWN,并且将mFirstTouchTarget赋值,才进入此区域,主要功能是拦截器 final boolean intercepted; if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null) { //disallowIntercept:是否禁用事件拦截的功能(默认是false),即不禁用 //可以在子View通过调用requestDisallowInterceptTouchEvent方法对这个值(FLAG_DISALLOW_INTERCEPT)进行修改,不让该View拦截事件 final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; //默认情况下会进入该方法 if (!disallowIntercept) { //调用拦截方法 intercepted = onInterceptTouchEvent(ev); ev.setAction(action); } else { intercepted = false; } } else { // 当没有触摸targets,且不是down事件时,开始持续拦截触摸。 intercepted = true; }
- 这一段的内容主要是为判断是否拦截。如果当前事件的MotionEvent.ACTION_DOWN,则进入判断,调用ViewGroup onInterceptTouchEvent()方法的值,判断是否拦截。
- 如果mFirstTouchTarget != null,即已经发生过MotionEvent.ACTION_DOWN,并且该事件已经有ViewGroup的子View进行处理了,那么也进入判断,调用ViewGroup onInterceptTouchEvent()方法的值,判断是否拦截。
- 如果不是以上两种情况,即已经是MOVE或UP事件了,并且之前的事件没有对象进行处理,则设置成true,开始拦截接下来的所有事件。
- 这也就解释了如果子View的onTouchEvent()方法返回false,那么接下来的一些列事件都不会交给他处理。
- 如果VieGroup的onInterceptTouchEvent()第一次执行为true,则mFirstTouchTarget = null,则也会使得接下来不会调用onInterceptTouchEvent(),直接将拦截设置为true。
- 当ViewGroup不拦截事件的时候,事件会向下分发交由它的子View或ViewGroup进行处理。
/* 从最底层的父视图开始遍历, ** 找寻newTouchTarget,即上面的mFirstTouchTarget ** 如果已经存在找寻newTouchTarget,说明正在接收触摸事件,则跳出循环。 */ for (int i = childrenCount - 1; i >= 0; i--) { final int childIndex = customOrder ? getChildDrawingOrder(childrenCount, i) : i; final View child = (preorderedList == null) ? children[childIndex] : preorderedList.get(childIndex); // 如果当前视图无法获取用户焦点,则跳过本次循环 if (childWithAccessibilityFocus != null) { if (childWithAccessibilityFocus != child) { continue; } childWithAccessibilityFocus = null; i = childrenCount - 1; } //如果view不可见,或者触摸的坐标点不在view的范围内,则跳过本次循环 if (!canViewReceivePointerEvents(child) || !isTransformedTouchPointInView(x, y, child, null)) { ev.setTargetAccessibilityFocus(false); continue; } newTouchTarget = getTouchTarget(child); // 已经开始接收触摸事件,并退出整个循环。 if (newTouchTarget != null) { newTouchTarget.pointerIdBits |= idBitsToAssign; break; } //重置取消或抬起标志位 //如果触摸位置在child的区域内,则把事件分发给子View或ViewGroup if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { // 获取TouchDown的时间点 mLastTouchDownTime = ev.getDownTime(); // 获取TouchDown的Index if (preorderedList != null) { for (int j = 0; j < childrenCount; j++) { if (children[childIndex] == mChildren[j]) { mLastTouchDownIndex = j; break; } } } else { mLastTouchDownIndex = childIndex; } //获取TouchDown的x,y坐标 mLastTouchDownX = ev.getX(); mLastTouchDownY = ev.getY(); //添加TouchTarget,则mFirstTouchTarget != null。 newTouchTarget = addTouchTarget(child, idBitsToAssign); //表示以及分发给NewTouchTarget alreadyDispatchedToNewTouchTarget = true; break; }
dispatchTransformedTouchEvent()
方法实际就是调用子元素的dispatchTouchEvent()
方法。其中dispatchTransformedTouchEvent()
方法的重要逻辑如下:
if (child == null) { handled = super.dispatchTouchEvent(event); } else { handled = child.dispatchTouchEvent(event); }
- 由于其中传递的child不为空,所以就会调用子元素的dispatchTouchEvent()。如果子元素的dispatchTouchEvent()方法返回true,那么mFirstTouchTarget就会被赋值,同时跳出for循环。
//添加TouchTarget,则mFirstTouchTarget != null。 newTouchTarget = addTouchTarget(child, idBitsToAssign); //表示以及分发给NewTouchTarget alreadyDispatchedToNewTouchTarget = true;
- 其中在
addTouchTarget(child, idBitsToAssign);
内部完成mFirstTouchTarget被赋值。如果mFirstTouchTarget为空,将会让ViewGroup默认拦截所有操作。如果遍历所有子View或ViewGroup,都没有消费事件。ViewGroup会自己处理事件。
5.3 View事件分发机制
06.事件冲突案例分析
6.1 滑动冲突说明
- 滑动冲突的场景
- 滑动冲突常发生于两个可滑动的控件发生嵌套的情况下。比如RecyclerView嵌套ListView,RecyclerView嵌套ScrollView,ViewPager嵌套RecyclerView等。
- 讨论的布局层次如下:
- Activity A,包含两个子View:ScrollView B、ViewPager C
- 什么是事件冲突
- 当父容器与子 View 都可以滑动时,就会产生滑动冲突。
- 根据两个控件的滑动方向,可以将滑动冲突分成两类:
- 一个是不同方向的滑动冲突,如外层控件垂直滑动,内层控件水平滑动。另一个就是同方向的滑动冲突,如内外两层控件都是垂直滑动。
- 如何解决冲突
- 解决 View 之间的滑动冲突的方法分为两种,分别是外部拦截法和内部拦截法
6.2 外部拦截法
- 什么叫做外部拦截法
- 外部拦截法,指的是从外部容器入手,去决定是否要去拦截事件,若拦截掉,子View就没法消费了。
- 父容器根据需要在
onInterceptTouchEvent
方法中对触摸事件进行选择性拦截。
- 外部拦截法的思路
- 根据实际的业务需求,判断是否需要处理 ACTION_MOVE 事件,如果父 View 需要处理则返回 true,否则返回 false 并交由子 View 去处理
- ACTION_DOWN 事件需要返回 false,父容器不能进行拦截,否则根据 View 的事件分发机制,后续的 ACTION_MOVE 与 ACTION_UP 事件都将默认交由父容器进行处理
- 原则上 ACTION_UP 事件也需要返回 false,如果返回 true,那么子 View 将接收不到 ACTION_UP 事件,子 View 的onClick 事件也无法触发
6.3 内部处理法
- 内部拦截法则是要求父容器不拦截任何事件
- 所有事件都传递给子View,子View根据需求判断是自己消费事件还是传回给父容器进行处理。
- 从内部容器出发去解决冲突。这依赖于ViewParent#requestDisallowInterceptTouchEvent()。
- 内部处理法的思路
- 内部拦截法要求父容器不能拦截 ACTION_DOWN 事件,否则一旦父容器拦截 ACTION_DOWN 事件,那么后续的触摸事件都不会传递给子View
- 滑动策略的逻辑放在子 View 的
dispatchTouchEvent
方法的 ACTION_MOVE 事件中,如果父容器需要处理事件则调用parent.requestDisallowInterceptTouchEvent(false)
方法让父容器去拦截事件
6.4 一个常见案例分析
- 场景解释:
- 为了能使整个Activity界面能够上下滑动,使用了ScrollView,将TabLayout和ViewPager的联合包裹在LinearLayout中,有滑动冲突问题。
- 外部拦截法解决滑动冲突
- 滑动方向不同之以ScrollView与ViewPager为例的外部解决法。处理是ScrollView,在onInterceptTouchEvent中处理冲突。
- 从 父View 着手,重写 onInterceptTouchEvent 方法,在 父View 需要拦截的时候拦截,不要的时候返回false。
- 举个例子,如果是左右滑动冲突,则在DOWN事件记录x和y坐标,在MOVE事件计算x和y轴移动距离,如果是x轴移动距离大于y轴,则返回false表示不拦截。将左右滑动交给ViewPager处理。
- 内部拦截法解决滑动冲突
- 从子View着手,父View 先不要拦截任何事件,所有的 事件传递给子View,如果子View需要此事件就消费掉,不需要此事件的话就交给 父View 处理。
- 实现思路 如下,重写 子View 的dispatchTouchEvent方法,在DOWN动作中通过方法requestDisallowInterceptTouchEvent(true) 先请求 父View 不要拦截事件,这样保证子View能够接受到MOVE事件,再在Action_move动作中根据自己的逻辑是否要拦截事件,不要的话再交给 父View 处理
6.5 选择合适的方案
- 进行个小结:
- “外部拦截法”所使用的原理是运用事件分发机制,去改变事件分发的路径,拦截内部容器的事件。
- “内部拦截法”使用的是requestDisallowInterceptTouchEvent()方法设置FLAG,不让父容器/祖先容器用onInterceptTouchEvent拦截方法。
- 如何选择合适解决办法
- 使用“内部拦截法”还是“外部拦截法”,首先需要去看实际业务需要我们怎么做,是从“内部”实现比较方便,还是从“外部”实现比较方便。
- 相较于“外部拦截法”,“内部拦截法”并没有减少事件分发的层级,因此看起来可能会更加复杂一些。并且也需要注意requestDisallowInterceptTouchEvent方法具体在哪个方法中使用。
- 若两个方法都能实现最终的效果,建议优先使用“外部拦截法”。