事件分发和NestedScrolling(一)

来源:互联网 发布:qq输入法 linux 编辑:程序博客网 时间:2024/06/06 01:41

前言

关于事件分发好像都已经说的很多了,网上也有很多资料,本来这篇文章主要是讲NestedScrolling(嵌套滚动),但是因为它和事件分发的相关性较大,所以还是讲一下。

事件分发的三个核心方法如下:

1、dispatchTouchEvent():分发事件2、onInterceptTouchEvent():决定父View是否拦截该事件不交由子View处理3、onTouchEvent():消费事件

这三个方法的关系可以用下面这段伪代码来表示:

//ViewGrouppublic boolean dispatchTouchEvent(MoveEvention event){    boolean result;    if(!disallowIntercept && onInterceptTouchEvent(event)){        result = onTouchEvent(event);    }else{        result = child.dispatchTouchEvent(event);    }    return result;}//子Viewpublic 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就可以了。

//父Viewpublic 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拦截事件。

//父Viewpublic boolean onInterceptTouchEvent(MotionEvent ev){    if(ev.getAction() == MotionEvent.ACTION_DOWN){        //如果拦截了ACTION_DOWN,那就拦截了所有的事件,子View没法接受事件        //也永远无法滚动了        return false;    }    return true;}//子Viewpublic 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(二)》

原创粉丝点击