Android 开发艺术探索读书笔记 3 -- View 的事件体系(下)

本篇文章主要介绍以下几个知识点:

  • View 的事件分发;
  • View 的滑动冲突。
hello,夏天 (图片来源于网络)

3.4 View 的事件分发

3.4.1 点击事件的传递规则

点击事件的分发,也就是对 MotionEvent 事件的分发过程,即当一个 MotionEvent 产生后,系统把这个事件传递给一个具体的 View,而这个传递的过程就是分发过程。

点击事件的分发过程由三个很重要的分发来完成 dispatchTouchEvent,onInterceptTouchEventonTouchEvent

  • puhlic boolean dispatch TouchEvent(MotionEvent event)
      用来进行事件的分发。如果事件能够传递给当前 View,那么此方法一定会被调用,返回结果受当前 View 的onTouchEvent 和下级 View 的 dispatchTouchEvent方法的影响,表示是否消耗当前事件。

  • public boolean onIntercept TouchEven(MotionEvent event)
      在上述方法内部调用,用来判断是否拦截某个事件,如果当前 View 拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。

  • public boolean onTouchEvent(MotionEvent event)
      在 dispatchTouchEvent方法中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前 View 无法再次接收到事件。

上述关系可以用以下伪代码表示:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        boolean consume = false;
        if(onInterceptTouchEvent(ev)){
            consume = onTouchEvent(ev);
        }else {
            consume = child.dispatchTouchEvent(ev);
        }
        return consume;

    }

通过上述的伪代码,大致可以了解传递的规则:对于一个根 ViewGroup 来说,点击事件产生以后,首先传递给它,这时它的 dispatchTouchEvent 就会被调用,若这个 ViewGroup 的 onIntereptTouchEvent方法返回 true 就表示它要控截当前事件,事件就会交给这个 ViewGroup 处理,即他的 onTouchEvent 方法就会被调用;若这个 ViewGroup 的 onIntereptTouchEvent 方法返回 false 就表示不需要拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的 onIntereptTouchEvent 方法就会被调用,如此反复直到事件被最终处理。

当一个 View 需要处理事件时,若它设置了 OnTouchListener,那么OnTouchListener 中的 onTouch方法会被回调。这时事件如何处理还要看onTouch 的返回值,如果返回 false,那当前的 View 的OnTouchListener 方法会被调用;若返回 true,那么onTouchEvent 方法将不会被调用。即,给 View 设置的OnTouchListener,其优先级比 onTouchEvent 要高。在 onTouchEvent 方法中,如果当前设置的有OnClickListener,那么它的 onClick方法会用。可以看出,平时我们常用的OnClickListener,其优先级最低,即处于事件传递的尾端。

当一个点击事件产生后,其传递过程遵循如下顺序:Activity -> Window -> View,即事件总是先传递给Activity,Activity 再传递给 Window,最后 Window 再传递给顶级 View。顶级View接收到事件后,就会按照事件分发机制去分发事件。考虑一种情况,若一个 view 的 onTouchEvent 返回 false,那么它的父容器的onTouchEvent 将会被调用,依此类推。若所有的元素都不处理这个事件,那么这个事件将会最终传递给Activity 处理,即 Activity 的 onTouchEvent 方法会被调用。

关于事件传递的机制,这里给出一些结论,如下:

(1)同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏慕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以 down 事件开始,中间含有数量不定的 move 事件,最后以 up 结束。

(2)正常情况下,一个事件序列只能被一个 View 拦截且消耗。其原因可以参考(3),因为一旦一个元素拦截了某此事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个 View 同时处理(除非一个 View 将本该自己处理的事件通过 onTouchEvent 强行传递给其他 View 处理)。

(3)某个 View 一旦决定拦截,那么这一个事件序列都只能由它来处理(若事件序列能够传递给它的话),并且它的 onInterceprTouchEvent 不会再被调用。

(4)某个 View 一旦开始处理事件,如果它不消耗 ACTON_DOWN 事件(onTouchEvent 返回了 false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素去处理,即父元素的 onTouchEvent 会被调用。也就是说事件一旦交给一个 View 处理,那么它就必须消耗掉,否则同一事件序列中剩下的事件就不再交给它来处理了。

(5)如果 View 不消耗除 ACTION_DOWN 以外的其他事件,那么这个点击事件会消失,此时父元素的onTouchEvent 并不会被调用,并且当前 View 可以持续收到后续的事件,最终这些消失的点击事件会传递给 Activity 处理。

(6)ViewGroup 默认不拦截任何事件。Android 源码中 ViewGroup 的 onInterceptTouchEvent 方法默认返回 false。

(7)View没有 onInterceptTouchEvent方法,一旦有点击事件传递给它,那么它的 onTouchEvent 方法就会被调用。

(8)View 的 onTouchEvent 默认都会消耗事件(返回true),除非它是不可点击的(clickable 和 longClickable 同时为 false)。View 的 longClickable 属性默认为 false,clickable 属性要分情况,比如Button 的 clickable 属性默认为 true,而 TextView 的 clickable 属性默认为 false。

(9)View 的 enable 属性不影响 onTouchEvent 的默认返回值。哪怕一个 View 是 disable 状态的,只要它的 clickable 或者 longclickable 有一个为 true,那么它的 onTouchEvent 就返会 true。

(10)onClick 会发生的前提实际当前的 View 是可点击的,并且它收到了 down 和 up 的事件。

(11)事件传递过程是由外到内的,即事件总是先传递给父元素,再由父元素分发给子 View,通过 requestDisallowInterptTouchEvent 方法可以在子元素中干预元素的事件分发过程,但是 ACTION_DOWN 除外。

附:图解 Android 事件分发机制

3.4.2 事件分发的源码解析

3.4.2.1 Activity 对点击事件的分发过程

点击事件用 MotionEvent 来表示,当一个点击操作发生的时,事件最先传递给 Activity,由 Activity 的 dispatchTouchEvent 来进行事件的派发,具体的工作是由 Activity 内部的 window 来完成的,window 会将事件传递给 decor view,decor view 一般都是当前界面的底层容器(setContentView 所设置的父容器),通过Activity.getWindow.getDecorView() 获得。先从 Activity 的 dispatchTouchEvent 开始分析:

    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        return onTouchEvent(ev);
    }

首先事件交给 Activity 所附属的 window 进行分发,若返回 true 整个事件循环就结束了,返回 false 意味着事件没人处理,所有 View 的 onTouchEvent 都返回 false,那么 Activity 的 onTouchEvent 就会被调用。

接下来看下 Window 是如何将事件传递给 ViewGroup 的,通过源码知道,Window 是个抽象类,而Window 的 superDispatchTouchEvent 方法也是抽象的,因此必须找到 Window 的实现类才行。

public abstract boolean superDispatchTouchEvent(MotionEvent event);

Window 的唯一实现类是 PhoneWindow,接下来看一下它是如何处理点击事件的。如下:

    public boolean superDispatchTouchEvent(MotionEvent ev){
        return mDecor.superDispatchTouchEvent(ev);
    }

可以看到,phoneWindow 传递给了 DecorView,类 DecorView 如下:

public class DecorView extends FrameLayout implements RootViewSurfaceTaker {

    private DecorView mDecor;

    @Override
    public final View getDecorView(){
        if(mDecor == null){
            installDecor():
        }
        return  mDecor;
    }
}

通过 ((ViewGroup)getWindow().getDecorView().findViewByld(android.R.id.content)).getChildAt(0) 可获取 Activity 所设置的 View,这个 mDecor 显然是 getWindow().getDecorView() 返回的 View,而通过 setContentView 设置的 View 是它的一个子 View。目前事件传递到了 Decorview 这里,由于 DecorView 继承自 FrameLayout 且是父 View,所以最终事件会传递给 View。从这里开始,事件已经传递到顶级 View 了,即在Activity中通过 seContentview 所设置的 View,另外顶级 View 也叫根 View,顶级 View 一般来说都是 VewGroup。

3.4.2.2 顶级 View 对事件的分发过程

回顾下点击事件在 View 中进行的分发:点击事件达到顶级View(一般是一个ViewGroup)后,会调用 ViewGiroup 的 dispatchTouchEvent 方法,然后的逻辑:若顶级 ViewGroup 拦截事件即 onIntercepTouchEvent 返回 true,则事件由 ViewGroup 处理,这时若 ViewGroup 的 mOnTouchListener被设置,则 onTouch 会被调用,否则 onTouchEvent 会被调用。也就是说,如果都提供的话,onTouch会屏蔽掉 onTouchEvent。在onTouchEvent中,若设置了 mOnTouchListener,则 onClick 会被调用。若顶级 ViewGroup 不拦截事件,则事件会传递给它所在的点击事件链上的子 View,这时子 View 的dispatchTouchEvent 会被调用。到此为止,事件已经从顶级 View 传递给了下一层 View,接下来的传递过程和顶级 View 是一致的, 如此循环,完成整个事件的分发。

首先看 ViewGroup 对点击事件的分发过程,其主要实现在 ViewGroup 的dispatchTouchEvent方法中,此方法较长,先看下面一段,它描述的是当View是否拦截点击事情这个逻辑:

            // 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;
            }

可以看出,ViewGroup 在事件类型为 ACTION_DOWNmFirstTouchTarget!=null 时会判断是否要拦截当前事件。当 ViewGroup 不拦截事件并将事件交由子元素处理时 mFirstTouchTarget != null。反过来,一旦事件由当前 ViewGroup 拦截时,mFirstTouchTarget !=null 就不成立。那么当 ACTION_MOVEACTION_UP 事件到来时,由于 actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null 这个条件为 false,将导致 ViewGroup 的 onInterceptTouchEvent 不会再被调用,并且同一序列中的其他事件都会默认交给它处理。

下面代码中,ViewGroup 会在 ACTION_DOWN 事件到来时做重置状态的操作,而在requsstTouchState方法中会对 FLAG_DISALLOW_INTERCEPT 进行重置,因此子 View 调用request-DisallowInterceptTouchEvent 方法并不能影响 ViewGroup 对 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();
            }

从上面的源码可得出结论,当 ViewGroup 决定拦截事件之后,那么后续的点击事件,将会默认交给他处理并且不再调用他的 onInterceptTouchEvent 方法。

接着再看 ViewGroup 不拦截事件时,事件会向下分发由他的子 View 进行处理:

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 (!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;
}

上面代码,首先遍历的是 ViewGroup 的所有子元素,然后判断子元素是否能接到点击事件。否能接到点击事件主要是两点来衡量:子元素是否在播动画和点击是按的坐标是否落在子元素的区域内。若某子元素满足这两个条件,那么事件就会传递给他处理。

 if (child == null) {
     handled = super.dispatchTouchEvent(event);
 } else {
    handled = child.dispatchTouchEvent(event);
 }

若子元素的 dispatchTouchEvent 返回 true,那么 mFirstTouchTarget 就会被赋值同时跳出 for循环:

newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;

这几行代码就完成了 mFirstTouchTarget 的赋值并且并终止对子元素的遍历。若子元素的 dispatchTouchEvent 返回 false,ViewGroup 就会把事件分给下一个子元素。

mFirstTouchTarget 真正的赋值过程是在 addTouchTarget 内部完成的,从下面的 addTouchTarget 的内部结构就可以看出,mFirstTouchTarget 其实是一种单链表的结构,mFirstTouchTarget 是否被赋值,将直接影响到 ViewGroup 对事件的拦截策略,若 mFirstTouchTarget 为 null,那么 ViewGroup 就默认拦截下来统一序列中所有的点击事件:

private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}

若遍历所有的子元素后事件都没有被合适的处理,有两种情况:第一是 Viewgroup 没有子元素,第二是子元素处理了点击事件,但是在 dispatchTouchEvent 中返回 false,这一般是因为子元素在 onTouchEvent 返回了false。这两种情况下,ViewGroup 会自己处理点击事件,如下:

// 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);
} 

上面的第三个参数 child 为 null,它会调用 supe.dispatchTouchEvent(event),从而就转到了 View 的 dispatchTouchEvent 方法,即点击事件开始交由 View 处理了。

3.4.2.3 View 对点击事件的处理

View 对点击事件的处理稍微简单一些, 注意这里的 View 不包含 ViewGroup。先看它的dispatchTouchEvent 方法:

    public boolean dispatchTouchEvent(MotionEvent event) {
        boolean result = false;
        . . .

        if (onFilterTouchEventForSecurity(event)) {
            if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
                result = true;
            }
            //noinspection SimplifiableIfStatement
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {
                result = true;
            }

            if (!result && onTouchEvent(event)) {
                result = true;
            }
        }

        . . .

        return result;
    }

View 是一个单独的元素,他没有子元素因此无法向下传递事件,只能自己处理点击事件。上面可以看出 View 对点击事件的处理首选会判断是否有设置 onTouchListener,若 onTouchListener 中的 onTouch 为 true,那么 onTouchEvent 就不会被调用,可见 onTouchListener的优先级高于onTouchEvent,其好处是方便在外界处理点击事件。

接着分析 onTouchEvent 的实现,先看当 View 处于不可用的状态下点击事件的处理过程,如下(不可用状态下的 View 照样会消耗点击事件,尽管它看起来不可用):

       if ((viewFlags & ENABLED_MASK) == DISABLED) {
            if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
                setPressed(false);
            }
            // A disabled view that is clickable still consumes the touch
            // events, it just doesn't respond to them.
            return (((viewFlags & CLICKABLE) == CLICKABLE
                    || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                    || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
        }

接着,若 View 设置有代理,那么还会执行 TouchDelegate 的onTouchEvent方法:

       if (mTouchDelegate != null) {
            if (mTouchDelegate.onTouchEvent(event)) {
                return true;
            }
        }

下面再看一下 onTouchEvent 中点击事件的具体处理,如下:

     if (((viewFlags & CLICKABLE) == CLICKABLE ||
                (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
                (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                    . . .

                        if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                            // This is a tap, so remove the longpress check
                            removeLongPressCallback();

                            // Only perform take click actions if we were in the pressed state
                            if (!focusTaken) {
                                // Use a Runnable and post this rather than calling
                                // performClick directly. This lets other visual state
                                // of the view update before click actions start.
                                if (mPerformClick == null) {
                                    mPerformClick = new PerformClick();
                                }
                                if (!post(mPerformClick)) {
                                    performClick();
                                }
                            }
                        }
                     break;
             }
            ....
            return true;
     }

从上面的代码来看,只要 View 的 CLICKABLE 和 LONG_CLICKABLE 有一个为 true,那么它就会消耗这个事件,即onTouchEvent 返回 true,不管它是不是 DISABLE 状态。当 ACTION_UP事件发生之后,会触发 performClick 方法,若 View 设置了onClickListener,那么performClick方法内部就会调用它的 onClick 方法,如下:

    public boolean performClick() {
        final boolean result;
        final ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnClickListener != null) {
            playSoundEffect(SoundEffectConstants.CLICK);
            li.mOnClickListener.onClick(this);
            result = true;
        } else {
            result = false;
        }

        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
        return result;
    }

View 的 LONG_CLICKABLE 属性默认为 false,而 CLICKABLE 属性是否为 false 和具体的 View 有关,确切的说是可点击的 View 其 CLICKABLE 为 true,不可点击的为 false,如 button 是可点击的,textview 是不可点击的。从源码可知道通过设置点击可改变状态:

   public void setOnClickListener(@Nullable OnClickListener l) {
        if (!isClickable()) {
            setClickable(true);
        }
        getListenerInfo().mOnClickListener = l;
    }

    public void setOnLongClickListener(@Nullable OnLongClickListener l) {
        if (!isLongClickable()) {
            setLongClickable(true);
        }
        getListenerInfo().mOnLongClickListener = l;
    }

到这里,点击事件的分发机制源码就分析完了。

3.5 View 的滑动冲突

3.5.1 常见的滑动冲突场景

常见的滑动冲突场景可简单分为如下3种:

  • 场景一:外部滑动方向和内部滑动方向不一致
  • 场景二:外部滑动方向和内部滑动方向一致
  • 场景三:上面两种情况的嵌套
滑动冲突的场景

场景1,主要是将 ViewPager 和 Fragment 配合使用所组成的页面滑动效果。在这种效果中,可以通过左右滑动来切换页面,而每个页面内部往往又是一个 Listview。本来这种情况下是有滑动冲突的,但是 ViewPager 内部处理了这种滑动冲突,因此采用 ViewPager 时我们无须关注这个问题,若采用的不是 ViewPager 而是 ScrollView等,那就必须手动处理滑动冲突了, 否则造成的后果就是内外两层只能有一层能够滑动,这是因为两者之间的滑动事件有冲突。除了这种典型情况外,还存在其他情况,如外部上下滑动、内部左右滑动等,但它们属于同一类滑动冲突。

场景2,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。在实际的开发中,这种场景主要是指内外两层同时能上下滑动或者内外两层同时能左右滑动。

场景3,是场景1和场景2两种情况的嵌套。虽然说场景3的滑动冲突看起来更复杂,但它是几个单一的滑动冲突的叠加,因此只需要分别处理内层和中层、中层和外层之间的滑动冲突即可,而具体的处理方法其实是和场景1、场景2相同的。

从本质上来说,这三种滑动冲突场景的复杂度其实是相同的,区别仅仅是滑动策略的不同,其解决方法是通用的。

3.5.2 滑动冲突的处理规则

对于场景1,其处理规则是:当用户左右滑动时,需要让外部的View拦截点击事件,当用户上下滑动时,需要让内部View拦截点击事件。这个时候我们就可以根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件。如下图所示,根据滑动过程中两个点之间的坐标就可以得出到到底由谁来拦截事行:比如可以依据滑动路径和水平方向做形成的夹角,也可以依据水平方向和竖直方向上的距离差来判断,某些特殊时候还可以依据水平和竖直方向的速度差来做判断。这里可以通过水平和竖直方向的距离差来判断,比如竖直方向滑动的距离大就判断为竖直滑动,否则判断为水平滑动。

滑动过程示意图

对于场景2来说,比较特殊,它无法根据滑动的角度、距离差以及速度差来做判断,但是这个时候一般都能在业务上找到突破点,比如业务上有规定:当处于某种状态时需要外部 View 响应用户的滑动,而处于另外一种状态时则需要内部 View 来响应 View 的滑动,根据这种业务上的需求我们也能得出相应的处理规则(比较抽象)。

对于场景3来说,它的滑动规则就更复杂了,具体方法和场景2一样,都是从业务的需求上得出相应的处理规则。

3.5.3 滑动冲突的解决方式

首先分析第一种滑动冲突场景,这也是最简单、最典型的一种滑动冲突,因为它的滑动规则比较简单,不管多复杂的滑动冲突,它们之间的区别仅仅是滑动的规则不同而已。

上面说过,针对场景1中的滑动,我们可以根据滑动的距离差来进行判断,这个距离差就是我们的的滑动规则。针对滑动冲突,要用到事件分发机制,这里给出两个解决办法:

  • 外部拦截法

所谓的外部拦截法是指点击事件都先经过父容器的拦截处理,若父容器需要此事件就拦截,反之,不拦截。外部拦截法得重写父容器的 onIterceptTouchEvent,在内部做相应的拦截即可,伪代码如下:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                // 1. ACTION_DOWN 事件,必须返回false,即不拦截 ACTION_DOWN 事件,
                // 因为一旦父容器拦截了 ACTION_DOWN,那么后续的 ACTION_MOVE 和
                // ACTION_UP事件都会直接交由父容器处理,事件就没法再传递给子元素了
                intercepted = false;
                break;

            case MotionEvent.ACTION_MOVE:
                // 2. ACTION_MOVE 事件,可以根据需要来决定是否拦截,
                // 若父容器需要拦截就返回true,否则返回false
                if("父容器需要当前点击事件"){
                    intercepted = true;
                }else {
                    intercepted = false;
                }
                break;

            case MotionEvent.ACTION_UP:
                // 3. ACTION_UP 事件,这里必须要返回false,
                // 因为 ACTION_UP 事件本身没有太多意义 
                intercepted = false;
                break;
        }
        mLastXIntercept = x;
        mLastYIntercept = x;
        return intercepted;
    }

上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其他均不需做修改并且也不能修改。

  • 内部拦截法

内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素要消耗此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和 Android 中的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作。内部拦截法需要重写子元素的dispatchTouchEvent方法,其伪代码如下:

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX =  x - mLastX;
                int deltaY =  x - mLastY;
                if("父容器需要此点击事件"){
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:

                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }

上述代码就是内部拦截法的典型代码,当面对不同的滑动策略只需要修改里面的条件即可,其他不需要做改动。

父元素修改如下:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int action = ev.getAction();
        if(action == MotionEvent.ACTION_DOWN){
            // 由于 ACTION_DOWN 事件并不受 FLAG_DISALLOW_DOWN 这个标记位的控制,
            // 一旦父容器拦截,那么所有的事件都无法传递到子元素中,会造成内部拦截不起作用
            // 因此这里返回 false
            return false;
        }else {
            // 父元素默认拦截除 ACTION_DOWN 之外的其他事件,
            // 这样当子元素调用 getParent().requestDisallowInterceptTouchEvent(true)方法时,
            // 父元素才能继续拦截所需要的事件
            return true;
        }
    }

下面通过一个实例来分别介绍这两种用法,我们来实现一个类似于 ViewPgaer 中嵌套 ListView 的效果,为了制造滑动冲突,我们写一个类似 ViewPager 的控件即可,名字叫做 HorizontalScrollViewEx。

为了实现 ViewPager 的效果,定义一个类似于水平的 LinearLayout,只不过它可以水平滑动,初始化时,在它的内部添加若干个 ListView,这样一来,由于ListView是可以竖直滑动的。而它本身就可以水平滑动,一个典型的滑动冲突(场景 1)就出现了。

首先 Activity 的实现如下:

public class MainActivity extends AppCompatActivity {

    public static final String TAG = "MainActivity";
    private HorizontalScrollViewEx mListContainer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Log.i(TAG,"onCreate");
        initView();
    }

    private void initView() {
        LayoutInflater inflater = getLayoutInflater();
        mListContainer = findViewById(R.id.container);
        // 屏幕宽高
        WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
        final int w= wm.getDefaultDisplay().getWidth();
        final int h = wm.getDefaultDisplay().getHeight();

        // 创建 3 个ListView (子元素)并把它加入到自定义的父容器 HorizontalScrollViewEx 中
        for (int i = 0; i < 3; i++) {
            ViewGroup layout = inflater.inflate(R.layout.content_layout,mListContainer,false);
            layout.getLayoutParams().width = w;
            TextView textview = (TextView) layout.findViewById(R.id.title);
            textview.setText("page"  + (i+1));
            layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),0));
            createList(layout);
            mListContainer.addView(layout);
        }
    }

    private void createList(ViewGroup layout) {
        ListView listview = (ListView) layout.findViewById(R.id.list);
        ArrayList<String>datas= new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            datas.add("names" + i);
        }
        ArrayAdapter<String>adapter = new ArrayAdapter<String>(this,R.layout.content_list_item,R.id.name,datas);
        listview.setAdapter(adapter);
    }
}

采用外部拦截法来解决这个问题,按照之前的分析,只需要修改父容器需要拦截的条件即可,对于本例来说,父容器的拦截条件就是滑动过程中水平距离差比竖直距离差要大,在这种情况下,父容器就拦截当前点击事件,根据这一个条件进行相应修改,修改其 onInterceptTouchEvent 如下:

public class HorizontalScrollViewEx extends ViewGroup {

    public static final String TAG = "HorizontalScrollViewEx";

    private int mChindrensize;
    private int mChindrenWidth;
    private int mChindrenIndex;
    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    //分别记录上次滑动的坐标(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    private void init() {
        mScroller = new Scroller(getContext());
        mVelocityTracker = VelocityTracker.obtain();
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = true;
                if (!mScroller.isFinished()) {
                    // 为优化滑动体验
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltax = x - mLastXIntercept;
                int deltaY = y = mLastYIntercept;
                if (Math.abs(deltax) > Math.abs(deltaY)) {
                    // 在滑动过程中,当水平方向的距离大就判断水平滑动,让父容器拦截事件
                    intercepted = true;
                } else {
                    // 而竖直距离大于就不拦截,事件就传递给了ListView,
                    // 从而 ListView能上下滑动,这就解决了冲突
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();
                int scrollToChildIndex = scrollX / mChindrenWidth;
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50) {
                    mChindrenIndex = xVelocity > 0 ? mChindrenIndex - 1 : mChindrenIndex + 1;
                } else {
                    mChindrenIndex = (scrollX + mChindrenWidth / 2) / mChindrenWidth;
                }
                mChindrenIndex = Math.max(0, Math.min(mChindrenIndex, mChindrensize - 1));
                int dx = mChindrenIndex * mChindrenWidth - scrollX;
                ssmoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

    private void ssmoothScrollBy(int dx, int i) {
        mScroller.startScroll(getScrollX(),0,dx,500);
        invalidate();
    }

    @Override
    public void computeScroll() {
        if(mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            postInvalidate();
        }
    }
}

采用内部拦截法来解决这个问题,按照之前的分析,我们只需要修改 ListView 的 dispatchTouchEvent 方法中的父容器的拦截逻辑,同时让父拦截 ACTION_MOVE 和 ACTION_UP 事件即可。自定义一个 ListView 重写其 dispatchTouchEvent 方法如下:

public class ListViewEx extends ListView {

    public static final String TAG = "ListViewEx";
    private HorizontalScrollViewEx mHorizontalScrollViewEx;
    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

    . . .

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int delatX = x - mLastX;
                int delatY = y - mLastY;
                if (Math.abs(delatX) > Math.abs(delatY)) {
                    mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(false);
                }
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(ev);
    }
}

除了对 ListView 的修改,还需要修改 HorizontalScrollViewEx 的 onInterceptTouchEvent方法如下:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        int action = event.getAction();
        if(action == MotionEvent.ACTION_DOWN){
            mLastX = x;
            mLastY = y;
            if(!mScroller.isFinished()){
                mScroller.abortAnimation();// 此行代码为优化滑动体验(非必需)
                return true;
            }
            return false;
        }else {
            return true;
        }
    }

以上就是内部拦截法的示例。从实现上来看,内部拦截法的操作要稍微复杂一些,因此推荐采用外部拦截法来解决常见的滑动冲突。


前面说过,只要根据场景1来得出通用的解决方案,那么对于场景2和场景3来说只需要修改相关滑动规则的逻辑即可,下面就来演示如何利用场景1得出的通用的解决方案来解决更复杂的滑动冲突。这里只分析场景2中的滑动冲突,对于场景3中的叠加型滑动冲突,解决思想一致,这里就不分析了。

对于场景2,它的解决方法和场景1一样,只是滑动规则不同而已,在前面己经得出了通用的解决方案,因此这里只需要替换父容器的拦截规则即可。(注:在场景2中的冲突,由于内部拦截法没有外部拦截法简单易用,所以推荐采用外部拦截法)


下面通过一个实际的例子来分析场景2,首先提供一个类 LinearLayout 的可以上下滑动的父容器 StickyLayout,然后在它的内部分别放一个 Header 和 一个 ListView,这样内外两层都能上下滑动,就形成了场景2。

这个 StickyLayout 的滑动规则:当 Header 显示时或者 ListView 滑动到顶部时,由 StickyLayout 拦截事件;当 Header 隐藏时,若 Listview 已经滑动到顶部并且当前手势是向下滑动的话,这时还是 StickyLayout 拦截事件,其他情况则由ListView拦截事件。

为解决其滑动冲突,需要重写父容器 StickyLayout 的 oninterceptTouchEvent 方法如下(至于ListView则不用做任何修改):

public class StickyLayout extends LinearLayout {

    private int mTouchSlop;
    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    // 分别记录上次滑动的坐标(oninterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    . . .

    @Override
    public boolean oninterceptTouchEvent(MotionEvent event) {
        int intercepted = 0;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastXIntercept = x;
                mLastYIntercept = y;
                mLastX = x;
                mLastY = y;
                intercepted = 0;
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                if (mDisallowInterceptTouchEventOnHeader && y <= getHeaderHeight()) {
                    // 1. 当事件落在Header上面时,父容器不会拦截事件
                    intercepted = 0;
                } else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
                    // 2. 若竖直距离差小于水平距离差,那么父容器也不会拦截事件
                    intercepted = 0;
                } else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
                    // 3. 当Header是展开状态并且是向上滑动时父容器拦截事件
                    intercepted = 1;
                } else if (mGiveUpTouchEventListener != null) {
                    if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
                        // 4. 当 ListView 滑动到顶部并且向下滑动时,父容器也会拦截事件
                        intercepted = 1;
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = 0;
                mLastYIntercept = mLastYIntercept = 0;
                break;
        }
        return intercepted != 0 && mIsSticky;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mIsSticky) {
            return true;
        }
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:

                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                mHeaderHeight += deltaY;
                setHeaderHeight(mHeaderHeight):
                break;
            case MotionEvent.ACTION_UP:
                // 这里做一下判断,当松开手时,自动向两边滑动(看当前所处位置决定往哪边滑)
                int destHeight = 0;
                if (mHeaderHeight <= mOriginalHeaderHeight * 0.5) {
                    destHeight = 0;
                    mStatus = STATUS_COLLAPSED;
                } else {
                    destHeight = mOriginalHeaderHeight;
                    mStatus = STATUS_EXPANDED;
                }
                // 慢慢滑向终点
                this.smoothSetHeaderHeight(mHeaderHeight, destHeight, 500);
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }
}

上述代码中,giveUpTouchEvent 是一个接口方法,用来判断 ListView 是否滑到顶部,由外部实现,具体如下:

    private boolean giveUpTouchEvent(MotionEvent event) {
        if (expandableListView.getFirstVisiblePosition() == 0) {
            View view = expandableListView.getChildAt(0);
            if (view != null && view.getTop() >= 0) {
                return true;
            }
        }
        return false;
    }

以上,滑动冲突的解决方法就介绍完毕了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容