Android View的事件体系(下)

2017-05-08  本文已影响0人  kongjn

接上一篇:Android艺术开发探索第三章————View的事件体系(上)

3.4 View 的事件分发机制

本节介绍 View 的事件分发机制。

这章文字超级多,不过都是精华,尽量不予以删减书中文字,留着以后忘记了,可以快速复习一遍

3.4.1 点击事件的传递规则

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

public boolean dispathTouchEvent ( MotionEvent ev)

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

public boolean onInterceptTouchEvent( MotionEvent ev)

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

public boolean onTouchEvent( MotionEvent ev)

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

我们可以大致了解点击事件的传递规则:对于一个根 ViewGroup 来说,点击事件产生后,首先会传递给它,这时它的 dispathTouchEvent 就会被调用,如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 true 就表示要拦截当前事件,接着事件就会交给这个 ViewGroup 处理,即它的 onTouch 方法就会被调用;如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 false,就表示不拦截当前事件,这时候当前事件就会继续传递给它的子元素,就这子元素的 dispathTouchEvent 方法就会被调用,如此反复直到事件被最终处理。

当一个 View 需要处理事件时,如果它设置了 OnTouchListener,那么 OnTouchListene r中的onTooch方法会被回调。这时事件如何处理还要看 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 方法会被调用。

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

(2)正常情况下,一个事件序列只能被一个 View 拦截且消耗。但是通过特殊手段可以做到,比如一个 View 将本该自己处理的事件通过 onTouchEvent 强行传递给其他 View 处理。

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

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

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

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

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

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

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

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

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

3.4.2 事件分发的源码解析

开始分析源码,没有源码一切都是软并卵。

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 的onTouch 都返回了 false,那么 Activity 的onTouchEvent 就会被调用。

接下来看 Window 是如何传递给 ViewGroup 的。查看源码我们知道, Window是一个抽象类,而 Window 的 superDisapatchTouchEvent 方法也是个抽象方法,因此我们必须找到 Window 的实现类才行。

/**
 * Abstract base class for a top-level window look and behavior policy.  An
 * instance of this class should be used as the top-level view added to the
 * window manager. It provides standard UI policies such as a background, title
 * area, default key processing, etc.
 *
 * <p>The only existing implementation of this abstract class is
 * android.policy.PhoneWindow, which you should instantiate when needing a
 * Window.  Eventually that class will be refactored and a factory method
 * added for creating Window instances without knowing about a particular
 * implementation.
 */
public abstract class Window {
...
    public abstract boolean superDispatchTouchEvent(MotionEvent event);
...
}

上面这段话的大概意思是:Window 类可以控制顶级 View 的外观和行为策略,它的唯一实现位于 android.policy.PhoneWindow 中,当你要实例化这个 Window 类的时候,你并不知道它的细节,因为这个类会被重构,只有一个工厂方法可以使用。尽管这看起来有点模糊,不过我们可以看一下 android.policy.PhoneWindow这个类,尽管实例化的时候会被重构,仅是重构而已,功能是类似的。

由于 Window 的唯一实现是 PhoneWindow,因此直接看 PhoneWindow 是如何处理点击事件的。

public class PhoneWindow extends Window implements MenuBuilder.Callback {
...
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);
    }
...
}

到这里逻辑就很清晰了,PhoneWindow 继承 Window 重写 superDispatchTouchEvent 将事件直接传递给了 mDecor ,这个 mDecor 是什么?请看下面:

public class PhoneWindow extends Window implements MenuBuilder.Callback {
    private DecorView mDecor;

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

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

这波源码看懂了,其实就是一个继承重写然后转移事件的一个套路。

// 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_DOWN 或者 mFirstTouchTarget != null。ACTION_DOWN 事件很好理解,mFirstTouchTarget 这个在后面的代码逻辑中可以看出来,当事件由 ViewGroup 的子元素成功处理时, mFirstTouchTarget 会被赋值并指向子元素。那么当 ACTION_DOWN 和 ACTION_UP 事件到来时,由于 (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) 这个条件为 false,将导致 ViewGroup 的 onInterceptTouchEvent 不会再被调用,并且同一序列中的其他事件都会默认交给它处理。
  当然,这里有一种特殊情况,那就是 FLAG_DISALLOW_INTERCEPT 标记位,这个标记位是通过 requestDisallowInterceptTouchEvent 方法来设置,一般用于子 View 中。FLAG_DISALLOW_INTERCEPT 一旦设置后,ViewGroup 将无法拦截除了 ACTION_DOWN 以外的其他点击事件。为什么说是除了 ACTION_DOWN 以外的其他事件呢 ?这是因为 ViewGroup 在分发事件时,如果是 ACTION_DOWN 就会重置 FLAG_DISALLOW_INTERCEPT 这个标记位,因此当面对 ACTION_DOWN 事件时,ViewGroup 总是会调用自己的 onInterceptTouchEvent 方法来询问自己是否要拦截事件,这一点从源码中也可以看出来。

// 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 不拦截事件的时候,事件会向下分发交由它的子 View 进行处理,这段源码如下所示:

final View[] children = mChildren;

    final boolean customOrder = isChildrenDrawingOrderEnabled();
    for (int i = childrenCount - 1; i >= 0; i--) {
        final int childIndex = customOrder ?
                getChildDrawingOrder(childrenCount, i) : i;
        final View child = children[childIndex];
        if (!canViewReceivePointerEvents(child)
                || !isTransformedTouchPointInView(x, y, child, null)) {
            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();
            mLastTouchDownIndex = childIndex;
            mLastTouchDownX = ev.getX();
            mLastTouchDownY = ev.getY();
            newTouchTarget = addTouchTarget(child, idBitsToAssign);
            alreadyDispatchedToNewTouchTarget = true;
            break;
        }
    }

上面这段代码逻辑也很清晰,首先遍历 ViewGroup 的所有子元素,然后判断子元素是否能够接收到点击事件。是否能够接收点击事件主要由两点来衡量:子元素是否在播动画和点击事件的坐标是否落在子元素的区域内。如果某个子元素满足这两个条件,那么事件就会传递给它来处理。可以看到,dispatchTransformedTouchEvent 实际上调用的就是子元素的 dispatchTouchEvent 方法,在它的内部有如下一段内容,而在上面的代码中 child 传递的不是 null,因此它会直接调用子元素的 dispatchTouchEvent 方法,这样事件就交由子元素处理了,从而完成了一轮事件分发。

 if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {...
 }---->>>
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;
        }
      

如果子元素的 dispatchEvent 返回 true,这时我们暂时不用考虑事件在子元素内部是怎么分发的,那么 mFirstTouchTarget 就会被赋值同时跳出 for 循环,如下所示:

if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
      ...
        newTouchTarget = addTouchTarget(child, idBitsToAssign);
        alreadyDispatchedToNewTouchTarget = true;
        break;
      ...
    }

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

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

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

如果遍历所有的子元素后事件都没有被适合地处理,这包含两种情况:第一种是 ViewGroup 没有子元素,第二种是子元素处理了点击事件,但是在 dispatchTouchEvent 中返回了 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,从前面的分析可以知道,它会调用 super.dispatchTouchEvent(event),很显然,这里就转到了 View 的 dispatchTouchEvent 方法,即点击事件开始交由 View 来处理。

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

 public boolean dispatchTouchEvent(MotionEvent event) {
        ...
        boolean result = false;
        ...
        if (onFilterTouchEventForSecurity(event)) {
         ...
            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 是一个单独的元素,它不需要向下传递事件,只能自己处理。从上面的源码可以看出 View 对点击事件的处理过程,首先会判断有没有设置 OnTouchListener,如果 OnTouchListener 中的onTouch 方法返回 true,那么OnTouchListener 就不会被调用,可见 OnTouchListener 的优先级高于 onTouch,这样做的好处是方便在外界处理点击事件。
  
  接着再分析 OnTouchEvent 的实现。先看当 View 处于不可用状态下点击时间的处理过程,如下所示。很显然,不可用状态下的 View 照样会消耗点击时间,尽管它看起来不可用。

public boolean onTouchEvent(MotionEvent event) {
      ...
        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 方法,这个 onTouchEvent 的工作机制看起来和 OnTouchListener 类似,这里不深入研究了。

    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 ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                        // 如果我们已经没有焦点,我们应该在触摸模式。
                        boolean focusTaken = false;
                        if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                            focusTaken = requestFocus();
                        }

                        if (prepressed) {
                            // 按钮被释放之前,我们实际上显示为按下。使它显示按下状态现在(调度点击),以确保用户看到它。
                            setPressed(true, x, y);
                       }

                        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,不可点击的 View 其 CLICKABLE 为 false,比如 Button 是可点击的,TextView 是不可点击的。通过 setClickable 和 setLongClickable 可以分别改变 View 的CLICKABLE 设为 true,setOnLongClickListener 则会自动将 View 的 LONG_CLICKABLE 设为 true,这一点从源码中可以看出来,如下所示:

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.4.1 节中的理论分析和相关结论,可以更好的理解事件分发。

3.5 View 的滑动冲突

本节开始介绍:滑动冲突。前面 4 节均是为本节服务的,通过本节的学习,滑动冲突将不再是个问题。

3.5.1 常见的滑动冲突场景

常见的滑动冲突场景可以简单分为三种:

滑动冲突的场景

  先说场景 1,主要是将 ViewPager 和 Fragment 配合使用所组成的页面滑动效果。在这种效果中,可以通过左右滑动来切换页面,但是每个页面内部往往又是一个 ListView。本来这种情况下是有滑动冲突的,但是 ViewPager 内部处理了这种滑动冲突,因此采用 ViewPager 时我们我无须关注这个问题,如果我们采用的不是 ViewPager 而是 ScrollView 等,那就必须手动处理滑动冲突了,否则造成的后果就是内外两层只能有一层滑动,这是因为两者之间的滑动时间有冲突。
  再说场景 2,这种情况稍微复杂一些,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。因为当手指开始滑动的时候,系统无法知道用户到底是想让哪一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层能滑动,要么就是内外两层都滑动的很卡顿。
  最后说场景 3,场景 3 是场景 1、2两种情况的嵌套。在许多应用中会有这么一个效果:内存有一个场景 1中的滑动效果,然后外层又有一个场景 2 的滑动效果。具体来说就是,外部有一个 SlideMenu 效果,然后内部有一个 ViewPager,ViewPager 的每一个页面又是一个 ListView。虽然说场景 3的滑动冲突看起来更加复杂,但是它是几个单一的滑动冲突叠加的,因此只需要分别处理内层和中层、中层和外层之间的滑动冲突即可,而具体的处理方法其实和场景 1、2相同的。

3.5.2 滑动冲突的处理规则

一般来说,不管滑动冲突多么复杂,它都有既定的规则,根据这些规则我们就可以选择合适的方法去处理。

对于场景1,它的处理规则是:当用户左右滑动时,需要让外部的 View 拦截点击事件,当用上下滑动的时候,需要让内部 View 拦截点击事件。具体来说是:根据水平滑动还是竖直滑动来判断到底由谁来拦截事件,如图 滑动过程示意图 所示,根据滑动过程中两个点之间的坐标就可以的出来到底是水平滑动还是竖直滑动。如何根据坐标来得到滑动的方向呢?这很简单,有很多可以参考,比如可以根据滑动路径和水平方向所形成的夹角,也可以根据水平方向和竖直方向上的距离来判断,某些特殊时候还可以根据水平和竖直方向的速度来做判断。这里我们使用哭了差来做判断。

滑动过程示意图

  对于场景 2 来说,比较特殊,它无法根据滑动的角度、距离差以及速度差来做判断,但是这个时候一般都能在业务上找到突破口,比如业务上有规定:当处于某种状态时需要外部 View 响应用户的滑动,而处于另外一种状态时则需要内部 View 来响应 View 的滑动,根据这种业务上的需求我们也能得出响应的处理规则,有了处理规则同样可以进行下一步处理。这种场景通过通过文字描述可能比较抽象,等下会通过实际例子来演示。

对于场景 3 来说,它的滑动规则就更复杂了,和场景 2 一样,它也无法直接根据滑动的角度、距离差以及速度差来做判断,同样还是智能从业务上找突破口。

3.5.3 滑动冲突的解决方式

针对场景 1 中的滑动,我们可以根据滑动的距离差来进行判断,这个距离差就是所谓的滑动规则。如果用 ViewPager 去实现场景 1 中的效果,我们不需要手动处理滑动冲突,因为 ViewPager 已经帮我们做了,所以这里不采用 ViewPager。

所谓的外部拦截法,就是点击事情都先进过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要就不拦截,这样就可以解决滑动冲突的问题,这种方法比较符合点击事件的分发机制。外部拦截法需要重写父容器的 onInterceptTouchEvent 方法,在内部做相应的拦截即可,这种方法的伪代码如下。

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean onIntercept = false;
        switch (ev.getAction()) {
            case ACTION_DOWN:
                onIntercept = false;
                break;
            case ACTION_MOVE:
                if (父容器需要当前点击事件) {
                    onIntercept = true;
                } else {
                    onIntercept = false;
                }
                break;
            case ACTION_UP:
                onIntercept = false;
                break;
            default:
                break;
        }
        return onIntercept;
    }

上述代码是外部拦截法的典型逻辑,正对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其他均不需要做修改并且也不能修改。这里对上述代码再描述一下,在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 这个事件,父容器必须返回 false,即不拦截 ACTION_DOWN 事件,因为一旦父容器拦截了ACTION_DOWN ,那么后续的 ACTION_MOVE 和 ACTION_UP 都会交由父容器处理,事件就没办法再传递给子元素了;其次就是 ACTION_MOVE 事件,这个事件可以根据需求来决定是否拦截;最后 ACTION_UP 事件,这里必须要返回 false,因为 ACTION_UP 事件本身没有太多意义。

考虑到一种情况,假设事件交由子元素处理,如果父容器在 ACTION_UP 时返回了 true,就会导致子元素无法接收到 ACTION_UP 事件,这个时候子元素的 onClick 事件就无法触发,但是父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交给它来处理,而 ACTION_UP 作为最后一个事件也必定可以传递给父容器,几遍父容器的 onInterceptTouchEvent
方法在 ACTION_UP 时返回了 false。

内部拦截法指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和 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: {
            parent.requestDisallowInterceptTouchEvent(true);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastX;
            int deltaY = y - mLastY;
            Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
            if (父容器需要此类点击事件) {
                parent.requestDisallowInterceptTouchEvent(false);
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            break;
        }
        default:
            break;
        }

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

上述代码是内部拦截法的典型代码,当面对不同的花哦东策略时只需要修改里面的条件即可。除了子元素需要做处理以外,父元素也要默认拦截除了 ACTION_DOWN 以外的其他事件,这样当子元素调用 parent.requestDisallowIntercptTouchEvent(false) 方法时,父元素才能继续拦截所需的事件。

注意一点:ACTION_DOWN 事件不收 FLAG_DISALLOW_INTERCEPT 这个标记位的控制,所以使用内部拦截法,父容器就不能拦截 ACTION_DOWN 事件。

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            return false;
        } else {
            return true;
        } 
    }

下面做一个实例来介绍两种方法。我们来实现一个类似于 ViewPager 中嵌套 ListView 的效果。为了实现 ViewPager 的效果,我们定义一个类似于水平的 LinerLayout 的东西,只不过它可以水平滑动,初始化时我们在它的内部添加若干个 ListView,这样一来,由于它内部的 ListView 可以竖直滑动,而它本身又可以水平滑动,因此一个典型的滑动冲突场景就出现了,并且这种冲突属于类型1的冲突。


冲突事件 1 实例

  首先来看一下 Activity 中的初始化代码,如下所示。

public class DemoActivity_1 extends AppCompatActivity {

    private static final String TAG = "DemoActivity_1";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo_1);
        initView();
    }
    //初始化View
    private void initView() {
        LayoutInflater inflater = getLayoutInflater();
        HorizontalScrollViewEx listContainer = (HorizontalScrollViewEx) findViewById(R.id.container);
        //获取屏幕尺寸
        int widthPixels = getScreenMetrics(this).widthPixels;
        int heightPixels = getScreenMetrics(this).heightPixels;
        LogUtil.log(widthPixels + "===" + heightPixels);
        for (int i = 0; i < 3; i++) {
            ViewGroup layout = (ViewGroup) inflater.inflate(R.layout.content_layout, listContainer, false);
            layout.getLayoutParams().width = widthPixels;
            TextView textView = (TextView) layout.findViewById(R.id.title1);
            textView.setText("page" + (i + 1));
            layout.setBackgroundColor(Color.rgb(255 / (i + 1), 255 / (i + 1), 0));
            createList(layout);
            listContainer.addView(layout);
        }
    }
    //创建ListView
    private void createList(ViewGroup layout) {
        ListView listView = (ListView) layout.findViewById(R.id.list);
        ArrayList<String> mDatas = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            mDatas.add("name " + i);
        }

        ArrayAdapter<String> adapter = new ArrayAdapter<>(this, R.layout.content_list_item, R.id.name, mDatas);
        listView.setAdapter(adapter);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                Toast.makeText(DemoActivity_1.this, "position = " + position, Toast.LENGTH_SHORT).show();
            }
        });
    }
//获取屏幕尺寸
private DisplayMetrics getScreenMetrics(Context context) {
        WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        DisplayMetrics dm = new DisplayMetrics();
        wm.getDefaultDisplay().getMetrics(dm);
        return dm;
    }
}

上述代码很简单,就是创建 3 个ListView 并且把 ListView 加入到我们自己定义的HorizontalScrollViewEX 中,这里 HorizontalScrollViewEX 是父容器,而 ListView 就是子元素,关于 HorizontalScrollViewEX 的代码需要在书中第四章会有详细的介绍,本文就先不做介绍了。

首先采用外部拦截法来解决这个问题,按照前面的分析,我们只需要修改父容器需要拦截事件的条件即可。对于本例来说,父容器的拦截条件就是滑动过程中水平距离差比竖直距离差大,在这种情况下,父容器就拦截当前点击事件。

public class HorizontalScrollViewEx extends ViewGroup {
...
       @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 = false;
            //abortAnimation这一句话主要是为了优化滑动体验而加入的,可要可不要
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
                intercepted = true;
            }
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastXIntercept;
            int deltaY = y - mLastYIntercept;
            //当水平方向滑动距离大于竖直方向滑动距离就返回true,拦截事件,反之则不拦截
            if (Math.abs(deltaX) > Math.abs(deltaY)) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;
            break;
        }
        default:
            break;
        }

        Log.d(TAG, "intercepted=" + intercepted);
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;

        return intercepted;
    }
...
}

当水平方向滑动距离大于竖直方向滑动距离就返回true,父容器拦截事件,ListView 就无法获取点击事件,反之父容器不拦截 ACTION_MOVE 事件,事件就传递给了 ListView ,这样 ListView 就能上下滑动了,如此滑动冲突就解决了。
  考虑到一种情况,如果用户正在水平滑动,但是在水平滑动停止之前如果用户再迅速进行上下滑动,就会导致夹棉在水平方向无法滑动到终点,为了避免这中情况,当水平方向正在滑动时,下一个序列的点击时间任然交给父容器来处理。

public class HorizontalScrollViewEx extends ViewGroup {
    private static final String TAG = "HorizontalScrollViewEx";

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;

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

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;
    ...

    public HorizontalScrollViewEx(Context context, AttributeSet attrs,
                                  int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
       ...
    }

    @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 / mChildWidth;
            mVelocityTracker.computeCurrentVelocity(1000);
            float xVelocity = mVelocityTracker.getXVelocity();
            if (Math.abs(xVelocity) >= 50) {
                mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
            } else {
                mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
            }
            mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
            int dx = mChildIndex * mChildWidth - scrollX;
            smoothScrollBy(dx, 0);
            mVelocityTracker.clear();
            break;
        }
        default:
            break;
        }

        mLastX = x;
        mLastY = y;
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measuredWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth, heightSpaceSize);
        } else {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth, measuredHeight);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;

        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth,
                        childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }

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

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

    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}

如果采用内部拦截法也是可以的,按照前面对内部拦截法的分析,我们只需要修改 ListView 的 dispatchTouchEvent 方法中的父容器的拦截逻辑,同时让父容器拦截 ACTION_MOVE 和 ACTION_UP 事件即可。为了重写 ListView 的 dispatchTouchEvent 方法,我们必须要自定义一个 ListView。

public class ListViewEx extends ListView {
    private static final String TAG = "ListViewEx";

    private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;

    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

    ...
    public void setHorizontalScrollViewEx2(
            HorizontalScrollViewEx2 horizontalScrollViewEx2) {
        mHorizontalScrollViewEx2 = horizontalScrollViewEx2;
    }

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

        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastX;
            int deltaY = y - mLastY;
            Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
            if (Math.abs(deltaX) > Math.abs(deltaY)) {
                mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            break;
        }
        default:
            break;
        }

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

}

除了对 ListView 所做的修改,我们还需要修改 HorizontalScrollViewEx。

public class HorizontalScrollViewEx2 extends ViewGroup {
...
     @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;
        }
...
    }

上面代码就是内部拦截法的实例,其中 mScroller.abortAnimation(); 这一句不是必须的,是为了优化体验增加的,内部拦截法的操作要稍微复杂一些,因此推荐采用外部拦截法来解决常见的滑动冲突。

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

下面用过一个实际的例子来分析场景 2,首先我们可以提供一个可以上下滑动的父容器SickLayout,它看起来就像一个可以上下滑动的竖直的 LinearLayout ,然后在它的内部分别放一个 Header 和 ListView,这样内外两层都能上下滑动,于是就形成了场景2中的滑动冲突。

public class StickyLayout extends LinearLayout {

    private int mTouchSlop;
    private int mLastX = 0;
    private int mLastY = 0;

    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    public StickyLayout(Context context) {
        super(context);
    }
    ...
    @Override
    public boolean onInterceptHoverEvent(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()) {
                    intercepted = 0;
                } else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
                    intercepted = 0;
                } else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
                    intercepted = 1;
                } else if (mGiveUpTouchEventListener != null) {
                    if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
                        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;
    }
...
}

从上面的代码来看,这个 SitckyLayout 的实现有点复杂,在第 4 章会详细介绍这个自定义 View 的实现思想,这里有个大概的印象就即可。下面我们主要看它的 onInterceptTouchEvent 方法中对 ACTION_MOVE 的处理。

@Override
    public boolean onInterceptHoverEvent(MotionEvent event) {
            switch (event.getAction()) {
                ...
                case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                if (mDisallowInterceptTouchEventOnHeader && y <= getHeaderHeight()) {
                    intercepted = 0;
                } else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
                    intercepted = 0;
                } else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
                    intercepted = 1;
                } else if (mGiveUpTouchEventListener != null) {
                    if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
                        intercepted = 1;
                    }
                }

分析上面代码的逻辑,父容器是 StickyLayout,子元素是 ListVIew。首先,当时间落在 Header 上面时父容器不会拦截事件;接着如果竖直距离差小于水平距离差,那么父容器也不会拦截时间;然后,当 Herader 是展开状态并且向上滑动时父容器拦截事件。另一种情况,当 ListView 滑动到顶部了并且向下滑动时,父容器也会拦截事件,进过层层判断就可以达到我们想要的效果了。另外。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;
    }

上面这个例子比较复杂,需要多多体会其中的写法和思想。

掌握住上面说的通用方法,其他基本都是一些逻辑问题。

上一篇下一篇

猜你喜欢

热点阅读