事件分发和NestedScrolling(一)

168 阅读16分钟
原文链接: blog.csdn.net

前言

关于事件分发好像都已经说的很多了,网上也有很多资料,本来这篇文章主要是讲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(),直接处理后续到来的事件

ViewGroupdispatchTouchEvent()方法的源码如下:

    @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_DOWNmFirstTouchTarget!=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(二)》