前言
关于事件分发好像都已经说的很多了,网上也有很多资料,本来这篇文章主要是讲NestedScrolling(嵌套滚动),但是因为它和事件分发的相关性较大,所以还是讲一下。
事件分发的三个核心方法如下:
1、dispatchTouchEvent():分发事件
2、onInterceptTouchEvent():决定父View是否拦截该事件不交由子View处理
3、onTouchEvent():消费事件
这三个方法的关系可以用下面这段伪代码来表示:
//ViewGroup
public boolean dispatchTouchEvent(MoveEvention event){
boolean result;
if(!disallowIntercept && onInterceptTouchEvent(event)){
result = onTouchEvent(event);
}else{
result = child.dispatchTouchEvent(event);
}
return result;
}
//子View
public boolean dispatchTouchEvent(MoveEvention event){
boolean result = onTouchEvent(event);
return result;
}
看得多了,每次说起来也倒背如流,但是除了这些,关于事件分发的每个细节大家是否都足够了解呢?
比如,子View接受触摸事件之后,父View真的不能再干涉了吗?父View拦截子View的事件之后,子View真的收不到任何事件了吗?事件冲突要怎么解决?最后的最后,知道普通的事件冲突有什么不完美的地方吗?
如果都不了解,或者有些不了解,那么恭喜你,这篇文章正好是写给你看的。
事件处理机制
说之前先和大家达成几个共识:
-
事件分发时由外向里,抛出时由里向外。 即分发时,事件先经过父View,然后到达子View;抛出时,先从子View然后到父View。
-
如果一个事件能到达该View,则一定会先走该View的dispatchTouchEvent()方法
-
父View一旦拦截事件,则不会再次调用onInterceptTouchEvent(),直接处理后续到来的事件。
-
子View一旦处理了ACTION_DOWN事件,则后续的所有事件都会交由它处理,否则后面的事件都不会交由它处理。 所以如果一个触摸事件父View想让子View处理,就一定不能拦截子View的
ACTION_DOWN事件。
一时消化不了没关系,让我们一条条的来看对照着看源码:
1、父View一旦拦截事件,则不会再次调用onInterceptTouchEvent(),直接处理后续到来的事件
ViewGroup中dispatchTouchEvent()方法的源码如下:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...
// 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;
}
...
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
// Update list of touch targets for pointer up or cancel, if needed.
if (canceled
|| actionMasked == MotionEvent.ACTION_UP
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
final int actionIndex = ev.getActionIndex();
final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
removePointersFromTouchTargets(idBitsToRemove);
}
...
}
代码第6~7行,如果当前是ACTION_DOWN事件,或者不是ACTION_DOWN但是已经有子View在处理事件,则判断是否需要拦截事件。这个很好理解,原因如下:
-
如果当前是
ACTION_DOWN事件,则说明开始了一个新的触摸事件需要开始新的分发流程,所以需要重新判断是否要拦截 -
如果当前正在一个分发流程当中,且
mFirstTouchTarget!=null(mFirstTouchTarget是单链表,指针指向的是当前触摸事件的触摸链表中的第一个触摸目标,它不为null说明当前可以找到能够消费事件的子View),则需要判断是否要拦截这个事件
其他不需要拦截的情况是:如果当前没有子View处理,当然是不需要拦截,直接走正常的分发流程,自己处理消费。
代码8~14行,上面判断了是否需要拦截,这里则判断是否能够拦截,因为子View可以禁止父View拦截触摸事件,如果有子View禁止了,这里则不能拦截了。
其他情况则默认拦截。
决定完是否需要拦截后,接下来对当前是否有子View正在处理事件分别进行处理。
代码第24~28行,如果当前没有子View处理事件,则直接走自己的事件分发流程。
第31~57行,则考虑有子View处理事件的情况。用while循环遍历mFirstTouchTarget单链表,依次调用dispatchTransformedTouchEvent方法对单链表中所有可能消费该事件的子View发送取消消费的事件,当父View确定要拦截事件的话,这里cancelChild的值是true,所以下面方法的形参cancel也是true。
第61~65行,取消完子View之后,调用resetTouchState()方法:
/**
* Resets all touch state in preparation for a new cycle.
*/
private void resetTouchState() {
clearTouchTargets();
resetCancelNextUpFlag(this);
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
mNestedScrollAxes = SCROLL_AXIS_NONE;
}
方法里面又调用resetTouchState()方法:
/**
* Clears all touch targets.
*/
private void clearTouchTargets() {
TouchTarget target = mFirstTouchTarget;
if (target != null) {
do {
TouchTarget next = target.next;
target.recycle();
target = next;
} while (target != null);
mFirstTouchTarget = null;
}
}
最终将mFirstTouchTarget置为null,所以下次当手指没有抬起继续在屏幕上滑动时,走进dispatchTouchEvent()方法判断是否需要拦截时,由于事件既不是ACTION_DOWN,mFirstTouchTarget!=null也不成立(resetTouchState方法中已经置为null),if的两个条件都不满足,所以intercepted的值很直接的就是true了。
所以,父View一旦拦截事件,则不会再次调用onInterceptTouchEvent(),这个共识我们已经达成了,因为一旦决定拦截,resetTouchState方法中就会将mFirstTouchTarget置为null,导致父View认为当前事件没有子View需要处理,当然不需要拦截所以也无需进入拦截的流程,默认自己消费。
那子View一旦处理了ACTION_DOWN事件,则后续的所有事件都会交由它处理,否则后面的事件都不会交由它处理。 这个怎么说呢?
2、子View一旦处理了ACTION_DOWN事件,则后续的所有事件都会交由它处理,否则后面的事件都不会交由它处理
在dispatchTouchEvent()方法中,有如下代码:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...
if (!canceled && !intercepted) {
...
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
...
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// If there is a view that has accessibility focus we want it
// to get the event first and if not handled we will perform a
// normal dispatch. We may do a double iteration but this is
// safer given the timeframe.
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
// The accessibility focus didn't handle the event, so clear
// the flag and do a normal dispatch to all children.
ev.setTargetAccessibilityFocus(false);
}
if (preorderedList != null) preorderedList.clear();
}
if (newTouchTarget == null && mFirstTouchTarget != null) {
// Did not find a child to receive the event.
// Assign the pointer to the least recently added target.
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}
...
}
第5~11行,如果事件没被取消且父View不拦截,则开始寻找可以消费该事件的子View。
第15~25行,循环遍历所有子View,依次寻找。
第35~41行,如果子View不能被focus,则跳过该子View,继续寻找。
第43~47行,如果事件发生的坐标不在该子View显示的区域内,则跳过该子View,继续寻找。
第58~77行,经过上面两步,在dispatchTransformedTouchEvent方法中尝试将该事件分发给该子View,如果分发成功,则认为该子View可以消费当前事件。
代码74行,将该子View加入可消费该事件的链表内。
若找到,则停止for循环,否则继续寻找。
也很清楚,在父View不拦截的情况下,mFirstTouchTarget指向的单链表中存储了可以消费当前事件的所有子View,如果有触摸事件且父View不拦截的情况下,父View分发时会循环遍历mFirstTouchTarget指向的链表中所有的子View,直到找到能够消费该事件的子View为止。详见上面代码31~57行。
所以,一旦mFirstTouchTarget不为null,则事件分发时就会在mFirstTouchTarget指向的链表中寻找可以消费事件的子View,换句话说,父View分发事件时,要么在mFirstTouchTarget指向的链表中寻找子View来消费,要么自己消费。
当ACTION_DOWN事件到来时,如果子View消费了,就会存储在mFirstTouchTarget指向的单链表中,后面的事件到来时就会被父View找到并且分发;如果不消费,就不会在链表中,后面的事件就不会被父View分发。
因此,子View一旦处理了ACTION_DOWN事件,则后续的所有事件都会交由它处理,否则后面的事件都不会交由它处理
3、子View接受触摸事件之后,父View真的不能再干涉了吗?
答案:不是。
子View接受触摸事件之后,该View被存储在mFirstTouchTarget指向的单链表中,当事件到来时,dispatchTouchEvent()方法的流程是:先检查父View要不要拦截,然后再循环遍历mFirstTouchTarget单链表。
因此,只要子View没有禁止父View拦截事件,父View在任何时机都可以拦截掉事件,让子View不再消费。
所以,子View接受触摸事件之后,父View真的不能再干涉了吗?这是不对的。
4、父View拦截子View的事件之后,子View真的收不到任何事件了吗?
答案:不是。
回到代码:
public boolean dispatchTouchEvent(MotionEvent ev) {
...
// 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;
}
...
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
...
}
代码5~19行,父View决定拦截事件时,得到的intercepted值为true。
24~28行,如果子View没有消费事件,则直接分发给自己。当然这里只考虑有子view消费事件的情况,所以不是走这里。
31~57行,这里考虑有子Viwe消费事件时,父View拦截事件时的情况。循环遍历所有的子View,并对其分发该事件。
38~43行,考虑是否要取消子View对该事件的消费,由于父View拦截事件时intercepted的值是true,所以这里cancelChild的值也是true,然后调用dispatchTransformedTouchEvent()方法
/**
* Transforms a motion event into the coordinate space of a particular child view,
* filters out irrelevant pointer ids, and overrides its action if necessary.
* If child is null, assumes the MotionEvent will be sent to this ViewGroup instead.
*/
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
// Canceling motions is a special case. We don't need to perform any transformations
// or filtering. The important part is the action, not the contents.
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;
}
...
}
代码13~22行,由于这里cancel的值是true,则把当前事件的action改成MotionEvent.ACTION_CANCEL,然后分发给子View。
所以就很明朗了,当父View决定拦截事件后,子View会收到ACTION_CANCEL的事件,然后父View会将可消费当前事件序列的子View信息(即mFirstTouchTarget指向的单链表)清空,所以下次触摸事件再次到来的时候,父View会直接消耗该事件。
因此,父View拦截子View的事件之后,子View真的收不到任何事件了吗?这是不对的,起码子View还会在被拦截的那一刻收到ACTION_CANCEL的事件。
子View不可能一直停留在ACTION_MOVE的状态,不管有没有被拦截,事情总归有头有尾对吧。虽然有点标题党了,但是这样一看是不是理解的更加深刻了呢?
处理事件冲突
上面说了事件分发的很多条准则,也是看源码总结出来的规律,接下来看看事件冲突的解决方案。
首先,事件冲突发生的场景主要有下面三种:
-
相同方向冲突:
ViewPager+SwipeBackLayout -
不同方向冲突:
ViewPager+RecyclerView -
上面两种同时出现
很多人碰到事件冲突可能觉得一脸茫然,无从下手。其实解决事件冲突有两种固定的方法,掌握了这两种方法,以后碰到事件冲突的问题基本上可以迎刃而解了。
这两种解决方案都有一个主动方来决定是否拦截事件,根据决定拦截的主动方可以分为外部拦截和内部拦截,即:
- 父View决定是否拦截时,称为外部拦截
- 子View决定是否拦截时,称为内部拦截
外部拦截
父View决定是否拦截事件,这个很简单,因为事件分发的机制本来就是分发时由外到里,抛出时由里到外,事件本是先经过父View,然后到达子View,所以如果父View想要拦截事件,直接在onInterceptTouchEvent()中返回true就可以了。
//父View
public boolean onInterceptTouchEvent(MotionEvent ev){
if(ev.getAction() == MotionEvent.ACTION_DOWN){
//如果拦截了ACTION_DOWN,那就拦截了所有的事件,子View没法接受事件
//也永远无法滚动了
return false;
}
if(父View需要处理事件){
return true;
}
return super.onInterceptTouchEvent(ev);
}
内部拦截
子View决定是否拦截事件,大致方案是:父View始终拦截除了ACTION_DOWN以外的事件,子View在dispatchTouchEvent()事件中控制是否禁止父View拦截事件。
//父View
public boolean onInterceptTouchEvent(MotionEvent ev){
if(ev.getAction() == MotionEvent.ACTION_DOWN){
//如果拦截了ACTION_DOWN,那就拦截了所有的事件,子View没法接受事件
//也永远无法滚动了
return false;
}
return true;
}
//子View
public boolean dispatchTouchEvent(MotionEvent ev){
int action = ev.getAction();
switch(action){
case MotionEvent.ACTION_DOWN:
//禁止父View拦截ACTION_DOWN事件(拦截了子View就废了)
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if(子View不需要处理事件了){
//打开父View可以拦截的开关,从此事件交给父View处理
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
}
//要返回true,否则收不到后面的事件了
return true;
}
子View决定是否拦截事件,说的更准确一点,其实是子View控制父View是否可以拦截子View的事件。
相对于外部拦截,这种方式稍难理解一些,因为和普通的分发流程是背道而驰的,但是理解之后会对事件分发机制有更加全面和深入的理解。
事件分发不完美之处
这里主要说的是解决事件拦截的部分,前面说了:
- 对于外部拦截,父View一旦拦截事件,则不会调用
onInterceptTouchEvent方法,会直接消费后面的事件 - 对于内部拦截,子View一旦打开允许父View拦截事件的开关,父View也会直接消耗完后续的所有事件,子View无法重新夺回掌控权
也就是说,父View一旦拦截了事件,子View无法重新再消费事件了。(出发手指重新抬起再按下。)
那有什么完美的解决方案呢?这就需要引出来我们这篇文章的主角了—-NestedScrolling(嵌套滚动)。
小结
说了这么多,把事件分发的整个流程总结一下:
-
事件分发时由外向里,抛出时由里向外。
-
如果一个View可以收到触摸事件,则一定会走到它的
dispatchTouchEvent()方法。 -
如果一个View想要收到完整的触摸事件,则它或者它的子View在
ACTION_DOWN到来的时候要返回true,否则不会收到后续的事件了,因为不处理ACTION_DOWN的时候该View不会被存储在mFirstTouchTarget链表中,下次分发事件的时候就不会被考虑到,如果mFirstTouchTarget中一个子View都没有,父View则会直接拦截事件进行消耗。你可能会问,子View消费事件的时候是子View对
ACTION_DOWN返回true啊,父View没有返回,为什么父View还会收到后面的事件,然后分发给子View呢?上面说了分发时由外向里,抛出时由里向外,你可能只懂了前半句,后半句说的是,子View抛出的结果是先经过父View,然后父View的父View,然后是父View的父View的父View一层层抛出去的,一旦子View返回了true,那它的父View们返回的都是true,代表他们可以处理这些事件,所以下次当事件再次到达时,会通过这些父View以及mFirstTouchTarget链表信息对应的找到真正消费的子View,所以并不是子View消费时返回了true,父View没有返回true,父View其实也是返回true的。 -
一旦父View决定拦截事件,
mFirstTouchTarget指向的链表信息会被重置,子View同时会收到ACTION_CANCEL的事件,以保住自己不会一直停在ACTION_MOVE的状态。 -
一旦父View决定拦截事件,则事件不会走到子View,父Viw也不会再调用自己的
onInterceptTouchEvent()方法,因为mFirstTouchTarget指向的链表信息已经被重置了。 -
父View是否拦截事件取决于两个条件:1、子View是否禁止了父View拦截事件;2、父View在
onInterceptTouchEvent()中决定自己是否需要拦截事件。 -
一般情况下,事件处理的接力棒只可能被交换一次:事件先给子View消费,然后父View拦截进行消费。这也是事件拦截的核心思想,无法让父View和子View多次交换拦截。(所以实际体验是在嵌套滑动的View上滑动时,子View滑了一部分交给父View滑,当反方向滑动父View滑了想让子View继续滑动时,会导致先卡顿一下,除非手指抬起再按下时继续滑动,子View才可以继续滑动。)
最后
关于解决事件冲突的两种方案,可以参考这个demo:TouchEvent
关于比事件分发更完美的解决事件冲突的方案—-NestedScrolling,请看《事件分发和NestedScrolling(二)》