本文用于记录各种场景下的事件接受、事件突然被父View拦截、子View在满足于一定条件下主动放弃事件等等的方法调用情况,以ScrollView和ScrollView嵌套,内层ScollView中包含多个Button,但是Button将不处理任何事件,并在内层ScrollView上滑动为例:
默认的视图结构:DecorView -> ViewGroup -> OutScrollView -> InnerScrollView -> CustomButton
1. 默认下的一次滑动
OutScrollView在该场景下不拦截任何事件,全交给内层处理,所以将其看成一个ViewGroup即可。
首先是ACTION_DOWN的下发,事件逐层下发,并且在所有经过的ViewGroup中,都会去调用onInterceptTouchEvent
来询问是否需要拦截事件,最后下发到了叶子节点CustomButton,但是CustomButton并不处理这个事件,它dispatchTouchEvent
直接返回了false,因此事件又下沉给它的父View:InnerScrollView:
ViewGroup(47454983,), dispatchTouchEvent:ACTION_DOWN
ViewGroup(47454983,), onInterceptTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), onInterceptTouchEvent:ACTION_DOWN
CustomButton(227238493), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), onTouchEvent:ACTION_DOWN,consumed:true
接下来是ACTION_MOVE事件:
ViewGroup( 47454983 ,), dispatchTouchEvent:ACTION_MOVE
ViewGroup( 47454983 ,), onInterceptTouchEvent:ACTION_MOVE
InnerScrollView( 49665076 ,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView( 49665076 ,), onTouchEvent:ACTION_MOVE,consumed: true
InnerScrollView( 49665076 ,), scolledY: 5
// 下一次ACTION_MOVE事件
ViewGroup(47454983,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
我们可以看到,即使一开始接受ACTION_DOWN的是InnerScrollView,它的父ViewGroup,还是能够「看到」这一次的事件的,这么设计是因为它的父ViewGroup随时可以去拦截它的事件,只不过在onInterceptTouchEvent
中没有去处理这个事件。
InnerScrollView的onTouchEvent返回了true,即它消费了本次事件,并且消费后造成了内部视图的5点滑动量。
而它内部的CustomButton,因为没有接收到ACTION_DOWN事件,它也就没有机会去接收到后续的ACTION_MOVE和UP事件了。
接下就是很多个ACTION_MOVE,直到ACTION_UP的出现:
InnerScrollView(49665076,), onTouchEvent:ACTION_MOVE,consumed:true
InnerScrollView(49665076,), scolledY:35
ViewGroup( 47454983 ,), dispatchTouchEvent:ACTION_UP
InnerScrollView( 49665076 ,), dispatchTouchEvent:ACTION_UP
InnerScrollView( 49665076 ,), onTouchEvent:ACTION_UP,consumed: true
InnerScrollView( 49665076 ,), scolledY: 35
2. 父OutScrollView主动拦截
此前我们在OutScrollView中的dispatchTouchEvent返回了false,所以它就和一个普通的ViewGroup没什么两样,并不会对内部的InnerScrollView造成干扰。
现在我们不去修改原有的逻辑,我们看看它的事件下发:
OutScrollView(47454983,), dispatchTouchEvent:ACTION_DOWN
OutScrollView(47454983,), onInterceptTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), onInterceptTouchEvent:ACTION_DOWN
CustomButton(227238493), dispatchTouchEvent:ACTION_DOWN
InnerScrollView(49665076,), scolledY:0
# ACTION_MOVE
OutScrollView(47454983,), dispatchTouchEvent:ACTION_MOVE
OutScrollView(47454983,), onInterceptTouchEvent:ACTION_MOVE
InnerScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(49665076,), onTouchEvent:ACTION_MOVE,consumed:true
InnerScrollView(49665076,), scolledY:0
可以看到到一个ACTION_MOVE是由内层的InnerScrollView消费的,只不过我们手指滑动的比较慢,它的视图偏移量为0,言下之意是:虽然InnerScrollView消费了ACTION_MOVE,但是它并没有造成滑动。因为ScrollView内部会计算滑动量:yDiff,只有yDiff > TouchSlop才能算作是一次有效的滑动,否则不视为滑动。
这么做的目的是优化点按的体验,毕竟人很难控制手指在屏幕上不产生一点移动完成一次点击。如果移动一个像素都算滑动的话,那Click几乎没法用了。
我们聚焦到真正地,造成滑动的那一次ACTION_MOVE:
InnerScrollView(227238493,), scolledY:0
# ↑上一个ACTION_MOVE
# ↓下一个ACTION_MOVE
CustomViewGroup(151305542), dispatchTouchEvent:ACTION_MOVE
CustomViewGroup(151305542), onInterceptTouchEvent:ACTION_MOVE
OutScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
OutScrollView(49665076,), onInterceptTouchEvent:ACTION_MOVE
InnerScrollView( 227238493 ,), dispatchTouchEvent:ACTION_CANCEL
InnerScrollView( 227238493 ,), onTouchEvent:ACTION_CANCEL,consumed: true
# ↓下一个ACTION_MOVE
CustomViewGroup(151305542), dispatchTouchEvent:ACTION_MOVE
OutScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
OutScrollView(49665076,), onTouchEvent:ACTION_MOVE,consumed:true
OutScrollView(49665076,), scolledY:4
OutScrollView(49665076,), dispatchTouchEvent:ACTION_MOVE
首先事件是经过了OutScrollView,但是事件派发到InnerScrollView的时候ACTION就已经变成了****ACTION_CANCEL,更重要的是接下来,OutScrollView又开始消费一个新的ACTION_MOVE(由系统,经过DecorView新派发出来的),此时摇身一变OutScrollView开始「独吞」这个事件了,它不再将事件交给InnerScrollView了,最后外部的OutScrollView被成功滑动了,整体产生了4点的偏移量。
促使OutScrollView拦截事件的的ACTION_MOVE,通过setAction,摇身一变变成了ACTION_CANCEL,但是event还是那个event,并且当前事件OutScrollView并没有因为拦截了事件就立即滑动,而是等到下一次的ACTION_MOVE才滑动。
2.2 调试
我们想要调试这个过程的话,我们需要在InnerScrollView的onTouchEvent上打上断点,并新增断点条件:
因为是源码调试,我们需要选择对应的源码和对应的模拟器版本:比如现在选中的是Android APi32,那么我们的源码和模拟器都应该选择32的,否则Debug进入系统代码的时候,比如View类中的代码,行号会和实际的代码行号对不上,第三方品牌的真机大多数也不太靠谱,即使对应Api版本,也很可能行号对应不上,除非用Pixel或者nexus等等原版未经过修改的系统。
观察函数的调用栈,我们可以定位到OutScrollView的dispatchTransformedTouchEvent
中,大致的内容如下:
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}
重点可以关注一下**event.setAction(MotionEvent.ACTION_CANCEL);
** 这个就是把原先的ACTION_MOVE更改为ACTION_CANCEL的方法。如果child不为空,则把这个取消事件派发给它。
child是什么?
child最终指向的是ViewGroup下的一个mFirstTouchTarget
对象,从名称中,我们大致就可以猜出来它的作用:本ViewGroup第一次触摸的对象。
mFirstTouchTarget在各个组件响应ACTION_DOWN时间的时候,全部为NULL,直到到第一个组件接收ACTION_DOWN,在这里是InnerScrollView
,我们看看所有控件的mFirstTouchTarget变量对应的child都是什么:
DecorView -> LinearLayout@892abf
LinearLayout@892abf -> FrameLayout
FrameLayout -> ActionBarOveraLayout
ActionBarOveraLayout -> ContentFrameLayout
ContentFrameLayout -> ConstraintLayout // 这个ConstraintLayout就是根布局
ConstraintLayout -> CustomViewGroup
CustomViewGroup -> OutScrollView
OutSrcollView -> LinearLayoutCompat@20889
LinearLayoutCompat@20889 -> InnerScrollView
InnerScrollView -> null
所有的ViewGroup,最终的mFirstTouchTarget都指向了接收触摸事件的那一个控件,最终这个链条会走向消费事件的View,也就是InnerScrollView。
但是一旦OutScrollView下发了ACTION_CANCEL之后,将滑动事件的消费权从InnerScrollView转移到自己身上的时候发生了什么呢?
当然是把这个链条切断了,所以它就成了最终的mFirstTouchTarget,事件也就都由它来消费。
CustomViewGroup -> OutScrollView
OutSrcollView -> LinearLayoutCompat@20889
OutSrcollView -> null
LinearLayoutCompat@20889 -> null
InnerScrollView -> null
所以,mFirstTouchTarget暂且可以看做是一个依赖于View Hierarchy的链表,最终的item就是当前的事件的接收者,假设现在有布局构成的mFirstTouch链:A->B->C->D->E,此时的事件就由E消费:
如果此时C要拦截事件,该链条就变成了A->B->C、D、E,事件由C来消费,DE不再在mTouchTarget构成的一个链之上:
3. 子View主动放弃事件
假设在某个场景之下,View主动放弃了事件,根据事件传递机制的特性,此时的事件会开始上浮给上层的视图,例如:A->B->C->D->E,这五个控件构成的视图树,如果E放弃了ACTION_DOWN事件,那么事件会上浮到D的dispatchTouchEvent
中,表现为E的dispatchTouchEvnet
方法调用弹出方法栈。
如果此时D要消费事件,则会在onTouchEvent中返回true,否则返回false,后者则会继续走上述的流程,上浮到C。
那么如果是E接受了ACTION_DOWN,然后主动放弃了某一个ACTION_MOVE,接下来会发生什么呢?
其实怀疑的点,就在于E放弃了ACTION_MOVE事件之后,E还能不能收到后续事件,如果收不到,后者是D会不会像面对ACTION_DOWN被E放弃了一样,去重新接收事件。
InnerScrollView(86112637,), scolledY:499
CustomViewGroup(149935399), dispatchTouchEvent:ACTION_MOVE
OutScrollView(70898644,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), onTouchEvent:ACTION_MOVE,consumed:true
InnerScrollView(86112637,), scolledY:504
// 上一次的滑动之后,偏移量达到了504,
// 下一次滑动开始时,ScrollY将超出500,超出500之后,InnerScrollView的onTouchEvent将会返回false,即不再处理;
CustomViewGroup(149935399), dispatchTouchEvent:ACTION_MOVE
OutScrollView(70898644,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), onTouchEvent:ACTION_MOVE,consumed:false
InnerScrollView(86112637,), scolledY:504
// next
CustomViewGroup(149935399), dispatchTouchEvent:ACTION_MOVE
OutScrollView(70898644,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), dispatchTouchEvent:ACTION_MOVE
InnerScrollView(86112637,), onTouchEvent:ACTION_MOVE,consumed:false
InnerScrollView(86112637,), scolledY:504
我们可以清楚地看到,即使我们的InnerScrollView在某一次的ACTION_MOVE中,在onTouchEvent返回false之后,此后的ACTION_MOVE仍然会下发到InnerScrollView上, 对应着上面的例子,ABCDE五个控件中,如果E放弃了ACTION_MOVE之后,仍然能够收到ACTION_MOVE事件,也就是说mFirstTouchTarget构成的链表没有断开的情况下,E控件还是能收到事件的。
mFirstTouchTarget的定义也越来与明朗了,就是当前ViewGroup在一个滑动过程中(按下,滑动,抬起)第一次触摸的View控件,每个ViewGroup的mFirstTouchTarget构成的链表的最后一项就是事件的消费者,如果中间的VewGroup主动去拦截事件,那么就会将余下的链表项目斩断,自己成为最后一个ListNode来消费事件。
如果控件开始不接受ACTION_DOWN事件,那么就放弃了自己被挂载在mFirstTouchTarget上的机会,自然也就没有机会再去接受事件了。
只有首个ACITON_MOVE会根据坐标确定被点击的View,其余的都是根据mFirstTouchTarget的链条来下发的,所以,父ViewGroup拦截事件的时候,很重要的一件事情就是断开ViewGroup自己的mFirstTouchTarget对下层View的连接。
而对于ACTION_DOWN事件来说,会根据手指触控的坐标,比如(500,500)来确定控件在当前ViewGroup中的位置,这个过程需要按顺序遍历所有的子View,直到找到某个子View,并且它能够接受处理(在dispatchTouchEvent和onTouchEvent中返回true)为止。
并且记录为mFirstTouchTarget,后续的无数多个ACTION_MOVE就不需要再次去根据坐标定位了,直接根据当前mFirstTouchTarget的child域,就可以找到下一个ViewGroup或者最终找到ACTION_DOWN的接收者,所以,ACTION_DOWN的下发和其它事件的下发是不一样的,前者是在查找最终消费该事件的View,而ACTION_MOVE/UP等等则是只需要沿着mFirstTouchTarget的链条,不断下发即可。
跟踪ACTION_DOWN事件的下发,我们可以发现:
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
大概意思就是,在收到ACTION_DOWN事件的时候会先清理既有的TouchTarget数据,也就是mFirstTouchTarget的数据。接着,判断一下是否需要拦截事件,和FLAG_DISALLOW_INTERCEPT
这个标记位相关,子View可以通过一个方法来请求该ViewGroup不要拦截,如果设置了之后,对应的disallowIntercept的数据为true,就不会拦截事件了。
// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
然后通过for循环遍历它的children:
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
……
你会发现,这里的for的起始下标是childrenCount - 1,也就是说for是倒着遍历的,为什么?
在一个视图中,如果我们在XML中,按照如下的顺序编写视图:
<FrameLayout>
<A />
<B />
<C />
<D />
<E />
</FrameLayout>
此时如果A的尺寸最大,B次之,E最小,在渲染出来后应该是这样的视图:
因为A是排在最靠前的,所以A会先被渲染出来,B次之,所以B盖在A上方,E在最上方。但是如果我们点击一个View,比如我们点击E,如果View的加入顺序去遍历,事件会先派发给A。所以这里View的排列顺序和我们的点击事件派发顺序是反着的,越靠上层的View越晚被加入视图,视觉上也就越靠上,自然而然也应该优先响应点击事件。
也就是说,View被显示的顺序越靠后,在视图层上就越靠上(靠近人眼),接受事件的优先级就越高。但是这个高 = View越晚渲染
回到正题,getAndVerifyPreorderedIndex其实是根据View在children中的下标,取出绘制的顺序。但是如果你使用getChildDrawingOrder重写了Draw的顺序,getChildDrawingOrder取出来的值就会发生改变, getAndVerifyPreorderedView就是去取View了。
在子View接受事件之后,返回到此处,调用addTouchTarget方法添加FirstTouchTarget。
/**
* Adds a touch target for specified child to the beginning of the list.
* Assumes the target child is not already present.
*/
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
而ACTION_MOVE,就是直接通过mFirstTouchTarget的child域来下发的:
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
4. 总结
上面我们分析了事件传递机制的三种非常常见的情况:
- 完整而正常的一次滑动;
- 父View主动拦截的事件派发;
- 子View主动放弃事件的派发(主要是ACTION_MOVE事件)
还有mFirstTouchTarget
在事件传递机制中的作用。
进一步验证了在(一)中,我们总结出来的几个规律:
- ACTION_DOWN事件优先派发给叶子节点的View,必须保证叶子节点的View能够有挂在mFirstTouchTarget链上的机会;
- 如果一个控件不接受ACTION_DOWN事件,那后续就不会得到ACTION_MOVE和ACTION_UP, 因为后续事件的下发是根据mFirstTouchTarget的child域下发的,如果在ACTION_DOWN中没有把自己挂在链上,后续的事件就没有机会再去消费了。
- 即使一个控件消费了ACTION_DOWN事件,也不意味着它就能收到本次触摸后续的所有事件。ACTION_MOVE事件在父View滑动和子View点击发生冲突的时候,可能会被父View拦截,并向子View派发ACTION_CANCEL事件,此后的ACTION_MOVE事件由父View来消费。
- 即使一个控件没有消费ACTION_DOWN事件,也可能会通过拦截事件的方式消费后续的ACTION_MOVE事件。
~end