美文网首页
Touch 事件分发相关点

Touch 事件分发相关点

作者: 你可记得叫安可 | 来源:发表于2020-08-30 17:26 被阅读0次

    概述

    • Touch 事件的分发是从上到下,从父到子:Activity -> ViewGroup1 -> ViewGroup1 的子 ViewGroup2 -> ... -> TargetView
    • Touch 事件的响应是从下到上,从子到父:TargetView -> ... -> ViewGroup1 的子 ViewGroup2 -> ViewGroup1 -> Activity
    boolean dispatchTouchEvent(MotionEvent ev)

    事件分发的处理函数,事件来自 Choreographer,开始分发的地方是 ActivityActivity 根据 UI 显示的情况,把事件传递给相应的 ViewGroup。该方法返回 true 表示事件不再往下分发,由于没有找到目标 View,因此也就表示将该事件丢弃

    开发者通常不需要重写该方法。除非是自定义 ViewGroup,需要自定义分发顺序

    boolean onInterceptTouchEvent(MotionEvent ev)

    事件的拦截函数,这个是暴露给开发者根据自身需求需要重写的方法,返回 true 表示拦截。对不同事件的拦截,行为上有差异的表现:

    • 如果 ACTION_DOWN 事件没有被拦截,顺利找到了 TargetView,那么后续的 MOVEUP 都能经由该函数下发。如果后续的 MOVEUP 下发时还有继续拦截的话,事件就只能传递到拦截层,并且向 TargetView 发出 ACTION_CANCEL
    • 如果 DOWN 事件下发时就被拦截,且拦截层没有消费事件,导致没有找到 TargetView,那么后续的 MOVEUP 都无法向下派发了,在 Activity 层就终止。
    boolean onTouchEvent(MotionEvent ev)

    响应处理函数,如果有设置对应 listener 的话,这里还会与 onTouchonClickonLongClick 有关。执行顺序是 onTouch -> onTouchEvent -> onClick -> onLongClick。返回 true 表示被处理。该函数返回 true 表示事件被消费

    requestDisallowInterceptTouchEvent 在 dispatchTouchEvent 中的应用

    有时候我们不希望父 View 拦截事件,比如出现滑动冲突的时候,安卓系统给我们提供了 requestDisallowInterceptTouchEvent() 方法来通知父 View 不要拦截事件。使用这个方法的正确姿势,网上都是这样写的:

    public boolean onTouchEvent(MotionEventevent){
        switch(event.getAction()){
            // 这里是解决滑动冲突的例子,一般来说滑动冲突都是因为父 View
            // 在收到滑动一定距离后,就在 onInterceptTouchEvent 中将 MOVE
            // 事件也给拦截掉了。因此才导致子 View 就滑动不了了。所以这里在
            // 子 View 还能收到 MOVE 事件时,就通知父 View 不要拦截。
            // 其实确定希望父 View 永远不要拦截的话,那么在 DOWN 中
            // requestDisallowInterceptTouchEvent() 也可以。
            case MotionEvent.ACTION_MOVE:
                parent.requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                parent.requestDisallowInterceptTouchEvent(false);
                break;
        }
    }
    

    那么它是如何让父 View 不再拦截事件的呢?我们来看下 dispatchTouchEvent() 中涉及这部分的源码。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        ...
        if (actionMasked == MotionEvent.ACTION_DOWN) {
            resetTouchState();
        }
        
        // 下面判断是否符合调用 onInterceptTouchEvent() 的条件
        final boolean intercepted;
        // 当前是 DOWN 事件,或者已经有目标 View
        if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
            // 这里 DOWN 事件时,disallowIntercept 一定为 false。
            // 因此 DOWN 事件时,onInterceptTouchEvent() 一定会被调
            final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
            if (!disallowIntercept) {
                intercepted = onInterceptTouchEvent(ev);
                ev.setAction(action); // restore action in case it was changed
            } else {
                // 既不是 DOWN 事件,又没有目标 View 时(什么时候会出现这种情况?)
                intercepted = true;
            }
        }
        ...
    }
    
    private void resetTouchState() {
        clearTouchTargets();
        resetCancelNextUpFlag(this);
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        mNestedScrollAxes = SCROLL_AXIS_NONE;
    }
    
    • 当父 View 收到 DOWN 事件时,会首先调用 resetTouchState() 方法将 FLAG_DISALLOW_INTERCEPT 标志位清零,这就是为什么子 View 如果在构造方法中调用 requestDisallowInterceptTouchEvent() 没有用的原因,子 View 最早都要在收到 DOWN 事件后调用 requestDisallowInterceptTouchEvent() 才会起作用。
    • 当第一次的 DOWN 事件下发时,父 View 一定会调用一次 onInterceptTouchEvent()。如果这时父 View 进行了拦截,那么子 View 就永远也收不到任何事件,也就没有机会调用 requestDisallowInterceptTouchEvent()

    事件如何由父 View 分发给子 View 的

    我们还是看父 View.dispatchTouchEvent()。总体上,父 View 是以广度遍历的形式来将事件分发给各个子 View 的:

    // 不是 CANCEL 并且不拦截
    if (!canceled && !intercepted) {
        // 在 DOWN 时去寻找目标 view
        if (actionMasked == MotionEvent.ACTION_DOWN) {
            final int actionIndex = ev.getActionIndex(); // always 0 for down
           final int childrenCount = mChildrenCount;
           if (newTouchTarget == null && childrenCount != 0) {
                final float x = ev.getX(actionIndex);
                final float y = ev.getY(actionIndex);
                ...
                for (int i = childrenCount - 1; i >= 0; i--) {
                    final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex); // 这里就是从 childView 数组中取得一个 View,只不过综合了一个自定义 Child 排序,这里先不管
                    // 如果坐标不在 child 的范围内,则跳过该 child
                    if (!child.canReceivePointerEvents() || !isTransforedTouchPointInView(x, y, child, null)) {
                        ev.setTargetAccessibilityFocus(false);
                        continue;
                    }
                    
                    newTouchTarget = getTouchTarget(child);
                    ...
                    // 这里将 DOWN 事件传给 child(该调用在 DOWN 事件的条件分支里),调用 child 的 dispatchTouchEvent() 方法去分发
                    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                        mLastTouchDownTime = ev.getDownTime();
                        mLastTouchDownX = ev.getX();
                        mLastTouchDownY = ev.getY();
                        newTouchTarget = addTouchTarget(child, idBitsToAssign);
                        alreadyDispatchedToNewTouchTarget = true;
                        break;
                    }
                }
           }
        }
        
        // 经过上面的逻辑后,只存在下面两种可能:
        // 1. 没有找到目标 view。那么该 ViewGroup 就看做普通 View:直接调用 super.dispatchTouchEvent(),向上回调
        // 2. 找到了目标 view,那么后续事件都可以直接到目标 view
        if (mFirstTouchTarget == null) {
            handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS); // 注意这里第三个参数是 child,传的是 null,这会导致方法中直接调用 super.dispatchTouchEvent()
        } else {
            if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                // 如果前面 DOWN 找到目标 view 时已经消费掉 DOWN 事件,那么这里直接返回 true
                handled = true;
            } else {
                // 如果不是 DOWN 事件,又被拦截了,则需要向之前的事件传递链上的 child 发送 CANCEL 事件
                final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted; 
                // 把事件传递给目标 view 或者 传递 CANCEL 事件给目标 view
                if (dispatchTransformedTouchEvent(ev, cancelChild, target.child, target.pointerIdBits)) {
                    handled = true;
                }
            }
        }
        
        if (canceled || actionMasked == MotionEvent.ACTION_UP) {
            // 如果是 CANCEL 或者 UP,重置 Touch 状态标识,mFirstTouchTarget 赋值为 null,后续的事件无法派发给 子 View 了。
            resetTouchState();
        }
    }
    

    子 View 在哪里消费事件,以及如何通知上层父 View 的?

    我们再看上面的代码,在广度遍历各个子 View 时,调用了 dispatchTransformedTouchEvent() 方法:

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIdBits) {
        final boolean handled;
        
        // 处理 CANCEL
        final int oldAction = event.getAction();
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) { // 前面事件分发时,没有找到目标 view,则调用 super.dispatchTouchEvent(),由于本身是 ViewGroup,所以 super 就是 View,也就是将这个 ViewGroup 当成普通 View 来处理。
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }
        ...
        // 普通事件,跟上面处理 CANCEL 一样,只不过 CANCEL 是往下派发 CANCEL。或者自己处理 CANCEL
        if (child == null) {
            handled = super.dispatchTouchEvent(transformedEvent);
        } else {
            final float offsetX = mScrollX - child.mLeft;
            final float offsetY = mScrollY - child.mTop;
            transformedEvent.offsetLocation(offsetX, offsetY);
            if (!child.hasIdentityMatrix()) {
                transformedEvent.transform(child.getInverseMatrix());
            }
            
            handled = child.dispatchTouchEvent(transformedEvent);
        }
        
        return handled;
    }
    

    无论事件怎么派发,最终都是派发到了叶子 View 上,或者没有子 View 的话,通过 super.dispatchTouchEvent() 将自己当成一个 View(而不是 ViewGroup) 来处理。我们看 ViewdispatchTouchEvent():

    public boolean dispatchTouchEvent(MotionEvent event) {
        ...
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            && li.mOnTouchListener) {
                result = true;
            }
        
        if (!result && onTouchEvent(event)) {
            result = true;
        }
        ...
        return result;
    }
    

    从上面可以看到,叶子 ViewdispatchTouchEvent() 中就没有 OnInterceptTouchEvent() 了,取而代之的是 OnTouchListenerOnTouchEvent,而且如果 OnTouchListener 先消费事件的话,那么 onTouchEvent 就不会再调用。

    我们再看 OnTouchEvent() 中做了什么,代码有点多,我们就捡几个重点说一下:

    • performClick() 是在 ACTION_UP 中进行的。
    image.png

    下面我们用 demo 来看看事件的具体分发,View 的层级关系依次是 MainActivity -> ParentLayout -> ChildLayout -> InnerButton

    Case 0:正常下发事件

    image.png

    Case 1: DOWN 在任意层被抛弃

    Case 1.1 抛弃后,后续事件不被拦截

    在 ChildLayout 层被抛弃,且后序事件不被拦截
    image.png
    • DOWNChildLayout 被抛弃
    • 之后事件走正常下发流程,而且只会分发到抛弃层,即 ChildLayout。并且将 ChildLayout 当做普通 View 来看待,即不会调用它的 onIntercept()。后续事件回传时,中间层的 onTouch() 也不会被调用,但是会调用 Activity.onTouch()
    在 Activity 层被抛弃
    image.png
    • 本质上跟上面是一样的。

    Case 1.2: 抛弃后,后续事件被中间层拦截

    image.png
    • DOWN 事件在哪一层被抛弃,系统就认为那一层是暂时的目标 View,因此后续事件会走正常流程下发到抛弃层
    • 后序事件下发过程中,中间层可拦截,由于有目标 View,因此中间层拦截后,会向下往目标 View 发送 CANCEL
      • 如果没有 View 消费 CNCEL,则 Activity.onTouch() 会收到 MOVE但是第一个拦截事件不会调用拦截层及其他中间层的 onTouch()
      • 如果有 View 消费 CANCEL,则往上回调时,不会有任何其他的 onTouch() 会被调用。
    • 拦截事件后的所有事件,都会将拦截层当做新的目标 View,事件下发到拦截层。
    • 说明被抛弃并不会改变目标 View,但是被拦截就会改变目标 View
    • 被抛弃并不会触发 CANCEL 事件的下发,但是拦截中间事件触发 CANCEL 发向目标 View
    • 一旦有 onTouch() 被调用,那么回溯时就不会再有 onTouch() 被调用。
    • 在任意层调用过 onTouch(),且事件未被消费,则 Activity.onTouch() 一定会被调用

    Case 2: DOWN 被消费

    Case 2.1:DOWN 被 InnerButton 消费,后序事件在 ChildLayout 层被抛弃
    image.png
    • DOWNInnerButton 消费没什么好说的,走正常流程。
    • MOVEChildLayout 抛弃,被抛弃前走正常流程
    • 之后事件 及 UP 走正常流程

    Case 2.2: DWON 被 InnerButton 消费,后续事件在 ChildLayout 层被拦截

    image.png
    • DOWNInnerButton 消费没什么好说的,走正常流程
    • MOVEChildLayout 拦截,由于之前 DOWN 已经被消费,因此有目标 View,向目标 View 发送 CANCEL.
    • 之后的事件只下发到新的目标 ChildLayout

    Case 3: DOWN 被中间层拦截

    case 3.1: DOWN 被 ChildLayout 拦截,且被消费
    image.png
    • 在还没有目标 View 时,拦截事件,会马上调用拦截层的 onTouch()。但是如果已经有目标 View 时,拦截事件,不会调用拦截层的 onTouch(),但是会向目标 View 发送 CANCEL 事件。后续事件才会将拦截层当做目标 View
    case 3.2: DOWN 被 ChildLayout 拦截,且未被消费
    image.png
    • DOWN 在下发时被 ChildLayout 拦截。由于此时还没有目标 View,因此马上调用拦截层的 onTouch()ChildLayout.onTouch() 也不消费事件,因此层层往上回溯 onTouch() 直到 Activity
    • 由于没有目标 View,因此之后的所有事件都只能到达 Activity

    相关文章

      网友评论

          本文标题:Touch 事件分发相关点

          本文链接:https://www.haomeiwen.com/subject/mquesktx.html