Android斩首行动—滑动冲突

4,253 阅读20分钟

前言

作为一名移动开发,我们对滑动冲突可以说是屡见不鲜。虽然Android已经提供了诸如NestedScrollView、CoordinatorLayout等支持嵌套滑动的组件,但其实并不能覆盖所有的滑动场景,我们终归会遇到需要自己去解决的滑动冲突。这篇文章将阐述如何处理常见的滑动冲突,而滑动冲突的处理本质上就是处理事件分发,所以我们从事件分发讲起,一步一步斩首滑动冲突。

事件分发

何为事件分发?

事件指的是屏幕触发事件——即Android中的TouchEvent/MotionEvent。每一次我们触摸屏幕,都会产生一连串的触摸事件,这些一连串的触摸事件合起来就是一个触摸事件序列。

触摸事件在Android官方API中由类MotionEvent来描述,不同的触摸事件对应不同的事件类型。事件类型分别有ACTION_DOWN、ACTION_UP、ACTION_MOVE、ACTION_CANCEL。

那什么叫分发呢?我们都知道Android是由View树进行渲染的。假设屏幕坐标为(11,11)的区域既属于一个LinearLayout,又属于LinearLayout下的一个Button,那我这次触碰所产生的触摸事件,是该给LinearLayout还是Button呢?当然,我们很确定这次触摸事件最终会被Button所处理。那触摸事件是怎么给到Button的呢?需要经过LinearLayout吗?怎样能让Button不处理呢?这就需要我们了解触摸事件(后文统称为事件)在View树上传递与消费的过程,这就是事件的分发。

事件分发机制

下面我们就详述事件是如何在View树上进行分发的。当然,除了View树外,还有Activity、Dialog等组件也会对事件进行处理,但他们都是用View树进行最终的渲染的,所以这里只拿Activity进行举例,结合 ViewGroup与View一起看。

三大金刚

首先我们需要知道三个核心方法:

dispatchTouchEvent()、onInterceptTouchEvent()、onTouchEvent()

Android就是靠这三个方法,解决了事件的分发。注意onInterceptTouchEvent()方法只有ViewGroup才会有,dispatchTouchEvent()、onTouchEvent()是Activity、ViewGroup、View都有的。它们的返回值都是布尔类型,但返回值代表的意义却不尽相同,下面我们会讲到。

ACTION_DOWN的分发

下面我们以ACTION_DOWN事件为例,讲解ACTION_DOWN事件的分发过程。

Demo准备

为了方便演示,做了一个Demo,最终长这个样子:

image.png 在各自的方法上加上日志:

Acitivity:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    Log.i("touch_event_test", "Activity dispatchTouchEvent return super")
    return super.dispatchTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
    Log.i("touch_event_test", "Activity onTouchEvent return super")
    return super.onTouchEvent(event)
}

ViewGroup1:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    Log.i("touch_event_test", "ViewGroup1 dispatchTouchEvent return super")
    return super.dispatchTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
    Log.i("touch_event_test", "ViewGroup1 onTouchEvent return super")
    return super.onTouchEvent(event)
}

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    Log.i("touch_event_test", "ViewGroup1 onInterceptTouchEvent return super")
    return super.onInterceptTouchEvent(ev)
}

ViewGroup2:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    Log.i("touch_event_test", "ViewGroup2 dispatchTouchEvent return super")
    return super.dispatchTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
    Log.i("touch_event_test", "ViewGroup2 onTouchEvent return super")
    return super.onTouchEvent(event)
}

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    Log.i("touch_event_test", "ViewGroup2 onInterceptTouchEvent return super")
    return super.onInterceptTouchEvent(ev)
}

View:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    Log.i("touch_event_test", "View dispatchTouchEvent return super")
    return super.dispatchTouchEvent(ev)
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
    Log.i("touch_event_test", "View onTouchEvent return super")
    return super.onTouchEvent(event)
}

默认返回值

这三个方法,默认都是返回的super.xxx()。这里我们先触碰一下View的区域,从日志上看一下事件是怎么分发的:

image.png

可以看到默认情况下,ACTION_DOWN事件的分发遵循以下流程图:

image.png

这个流程图可以看成是一个U型图,从Activity的dispathchTouchEvent方法开始,如果都是返回super的话,会一直到Activity的onTouchEvent结束。

改变dispatchTouchEvent返回值

现在我们将ViewGroup2的dispatchTouchEvent返回值设置为false,看看会发生什么:

image.png

可以看到,当ViewGroup2的dispatchTouchEvent返回false时,TouchEvent会传递给父View(即ViewGroup1)的onTouchEvent方法,然后再继续往上分发。用流程图表示:

image.png

再将ViewGroup2的dispatchTouchEvent返回值设置为true,日志:

image.png

可以看到,当ViewGroup2的dispatchTouchEvent返回true时,TouchEvent会直接在这里消费掉,不再继续分发。流程图:

image.png

改变onTouchEvent返回值

下面我们改变ViewGroup2的onTouchEvent的返回值,另外两个方法都是返回super。先将返回值设为false,日志:

image.png

可以看到,当onTouchEvent返回false时,事件分发跟默认返回super是一样的,仍然是按U型链进行分发,将事件传递给父View的onTouchEvent。流程图:

image.png

当我们将onTouchEvent返回值设置为true时,日志:

image.png

可以看到,当onTouchEvent返回true时,事件就在这里消费掉了,不再继续分发。流程图:

image.png

改变onInterceptTouchEvent返回值

现在我们设置ViewGroup2的onInterceptTouchEvent返回值为false,观察日志:

image.png

可以看到,onInterceptTouchEvent返回false跟onTouchEvent返回false是一样的,都是以默认的路径继续分发事件。流程图:

image.png

下面设置onInterceptTouchEvent返回值为true,观察日志:

image.png

可以看到,当ViewGoup2的onInterceptTouchEvent返回true时,事件被分发到了ViewGoup2的onTouchEvent方法,但没有消费掉,而是继续分发。流程图:

image.png 之前有提到,只有ViewGroup才会有onInterceptTouchEvent方法。为什么呢?因为我们可以从上面的几个流程图中看到,Activity的dispatchTouchEvent方法在返回false时,事件会分发到Activity的onTouchEvent方法。View的dispatchTouchEvent方法在返回super时,事件也同样能分发到View的onTouchEvent方法。而ViewGroup的dispatchTouchEvent方法在返回false和super时是将事件往下分发,在返回true时是直接消费,通过改变dispatchTouchEvent方法的返回值根本不能直接分发到自己的onTouchEvent方法。所以针对ViewGroup会有一个onInterceptTouchEvent方法,来让它可以选择将事件分发给自己的onTouchEvent方法。

小结

事件的三个方法在View中默认返回都是super,按U型链进行分发。

对于dispatchTouchEvent方法:

    • 返回true,消费事件
    • 返回false,如果不是Activity,会将事件分发到上一级View的onTouchEvent。如果是Activity,因为没有上一级View了,就会直接消费事件。

对于onTouchEvent方法:

    • 返回true,消费事件
    • 返回false,将事件分发到上一级View的onTouchEvent

对于onInterceptTouchEvent方法:

    • ViewGroup特有
    • 返回true,将事件分发到自己的onTouchEvent方法
    • 返回false,将事件分发到下一级View的dispatchTouchEvent
    • 不管返回值是什么,都不会消费事件,只起到分发作用

ACTION_UP、ACTION_MOVE的分发

下面我们来看一下,ACTION_UP事件是怎么分发的,跟ACTION_DOWN有哪些不同。日志中,特意加上了事件的ACTION,枚举如源码所示:

image.png

默认返回值

上一节我们知道,默认情况下,ACTION_DOWN是沿着三个方法组成的U型链进行分发。那默认情况下,ACTION_UP的分发,如日志所示:

image.png

可以看到,ACTION_UP是没有像ACTION_DOWN一样沿着U型链分发的,它最终只走了Activity的两个方法。用流程图可以清晰地表示出来:

image.png

为什么ACTION_UP不跟ACTION_DOWN一样,沿着U型链走一圈呢?这里直接给结论:ACTION_UP的分发路径,取决于ACTION_DOWN事件最终是在哪里被消费的。我们可以试着改变ACTION_DOWN事件的消费位置,来验证这个结论。

改变dispatchTouchEvent返回值

首先将ViewGroup2的dispatchTouchEvent方法返回true,观察日志:

image.png

红色是ACTION_DOWN的分发,绿色是ACTION_UP的分发。

流程图:

image.png

可以看到ACTION_DOWN事件在ViewGroup2的dispatchTouchEvent被消费了,ACTION_UP跟ACTION_DOWN的分发路径一致,同样也是在ViewGroup2的dispatchTouchEvent被消费了。

改变onTouchEvent返回值

下面我们将ViewGroup2的onTouchEvent方法返回true,观察日志:

image.png 流程图:

image.png

可以看到,由于ACTION_DOWN在ViewGroup2的onTouchEvent处被消费了,所以ACTION_UP也在ViewGroup2的onTouchEvent处被消费。但ACTION_UP的分发路径有所不同,相比ACTION_DOWN的分发路径相当于抄了一条近路。因为已经知道ACTION_DOWN“路过”View时没有被消费,所以ACTION_UP就不用再次走View了,而是直接从自己的dispatchTouchEvent分发到自己的onTouchEvent处。

如果将ViewGroup2的onInterceptTouchEvent事件也返回true,我们可以预知ACTION_UP的路径跟上面的结果是一样的。这里需要注意一点:这里都没有走ViewGroup2的onInterceptTouchEvent事件,但ViewGroup1的onInterceptTouchEvent事件是有走到的。 所以同样有一个结论:当ACTION_DOWN事件在ViewGroup的onTouchEvent处被消费了,该ViewGroup的onInterceptTouchEvent是收不到ACTION_UP事件的。

DOWN事件与UP事件分开拦截

下面讲个复杂些的例子。既然我们可以干涉ACTION_DOWN的分发,那么同样也能干涉ACTION_UP的分发。比如我们现在在ViewGroup2的onTouchEvent方法中返回true消费ACTION_DOWN事件,然后在ViewGroup1的dispatchTouchEvent方法中返回false分发ACTION_UP事件,日志如下:

image.png

可以看到,ACTION_UP在dispatchTouchEvent返回false时,表现也是跟ACTION_DOWN事件一样,分发给了上一个View的onTouchEvent方法。这样,原先的ACTION_UP分发路径也就被拦截了,ViewGroup1将收不到ACTION_UP事件。所以我们也能同样得出结论:ACTION_UP事件分发的干涉逻辑跟ACTION_DOWN是一样的。 如果有同学观察仔细的话,可以发现在红框内,VIewGroup2额外收到了两个ACTION_CANCEL事件,这是为什么呢?下面会讲到。

至于ACTION_MOVE事件,通过上面几个ACION_UP例子中的日志,我们可以看出来,ACTION_MOVE事件的分发逻辑,跟ACTION_UP事件的分发逻辑是保持一致的。感兴趣的同学可以自己去试一下。

小结

  • ACTION_UP的消费位置在不干涉的情况下,与ACTION_DOWN的消费位置一致
  • ACTION_UP事件分发的干涉逻辑跟ACTION_DOWN是一样的
  • 当ACTION_DOWN事件在ViewGroup的onTouchEvent处被消费了,该ViewGroup的onInterceptTouchEvent是收不到ACTION_UP事件的
  • ACTION_MOVE事件的分发逻辑与ACTION_UP事件的分发逻辑相同

ACTION_CANCEL的分发

上一节ACTION_UP的例子中,我们有发现ACTION_CANCEL的身影,这里直接用那个例子的日志看一下:

image.png

这个例子中我们拦截了ACTION_UP事件,在ViewGroup1的dispatchTouchEvent方法中将ACTION_UP事件直接分发给了ViewGroup1的onTouchEvent事件。而ACTION_UP事件原本应该经过ViewGroup2的dispatchTouchEvent方法与onTouchEvent方法,现在不经过了,恰巧这里(上图黄框)打印出来了ACTION_CANCEL事件。也就意味着:当ACTION_UP事件被上一层View拦截时,未分发到ACTION_UP事件的方法会收到ACTION_CANCEL事件

我们不妨再做个例子验证一下,将ACTION_DOWN事件在View的onTouchEvent方法消费掉,同时将ACTION_UP事件在ViewGroup2的dispatchTouchEvent返回true。日志如下:

image.png 流程图:

image.png

可以看到,View的dispatchTouchEvent和onTouchEvent方法,本可以收到ACTION_UP事件。但由于上层View拦截的原因,没有收到,此时它们就会收到ACTION_CANCEL事件。同理,ACTION_MOVE事件被上层容器拦截,子容器也是会收到CANCEL事件的,感兴趣的同学可以自行验证一下。

另外还有几个场景同样会触发ACTION_CENCEL,因为不怎么常见,所以这里只列出来一下,来源网上:

  • ACTION_DOWN初始化操作中
  • 在子View处理事件的过程中被父View中移除
  • 子View被设置为了PFLAG_CANCEL_NEXT_UP_EVENT标志位时

需要注意的是,手势滑出View的范围并不会触发ACTION_CANCEL,这个过程中即使滑出范围了,仍然会一直触发ACTION_MOVE事件,并最后触发ACTION_UP事件,只是不会响应点击罢了。

OnTouchListener和OnClickListener

我们开发时肯定会遇到OnTouchListener和OnClickListener,那么他们俩跟三大金刚是什么关系呢?我们不妨看下源码中是怎么实现的。定位到View的dispatchTouchEvent方法中的这段代码:

if (li != null && li.mOnTouchListener != null
        && (mViewFlags & ENABLED_MASK) == ENABLED
        && li.mOnTouchListener.onTouch(this, event)) {
    result = true;
}

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

在dispatchTouchEvent中,会判断View是否设置了OnTouchListener,如果设置了OnTouchListener,就会直接拦截事件,dispatchTouchEvent方法返回true,调用OnTouchListener的onTouch方法,而不会再触发后续的onTouchEvent方法。

再定位到View的onTouchEvent方法中的这段代码:

if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
    switch (action) {          
        case MotionEvent.ACTION_UP:
            if (!post(mPerformClick)) {
                performClickInternal();
            }

可以发现是在onTouchEvent方法中,判断了View是否可点击。若可点击且设置了OnClickListener,那么就会调用OnClickListener的onClick方法。

所以我们可以得到一个结论:按优先级排序,OnTouchListener>OnTouchEvent>OnClickListener。若设置了OnTouchListener,则不会触发后面两者。OnClickListener在ACTION_UP后触发。

滑动冲突

滑动冲突的场景

滑动冲突常发生于两个可滑动的控件发生嵌套的情况下。比如RecyclerView嵌套ListView,RecyclerView嵌套ScrollView,ViewPager嵌套RecyclerView等。ViewPager之所以没有滑动冲突是因为它本身就已经帮我们解决掉了。但其它没帮我们处理的情况就需要我们自己写代码去处理。

典型的,根据两个控件的滑动方向,可以将滑动冲突分成两类:一个是不同方向的滑动冲突,如外层控件垂直滑动,内层控件水平滑动。另一个就是同方向的滑动冲突,如内外两层控件都是垂直滑动。

下面举一个不同方向滑动冲突的例子。父容器ScrollViewParent,嵌套HorizontalScrollViewChild,ScrollViewParent可垂直滑动,HorizontalScrollViewChild可水平滑动。如图所示:

image.png

当我们手指放在HorizontalScrollViewChild的区域内并竖直滑动时,我们发现是可以滚动外层的ScrollViewParent的。说明ScrollView本身是解决了部分的滑动冲突的,否则HorizontalScrollViewChild如果消费了MOVE事件,ScrollViewParent就消费不了了,也就无法竖直滑动。观察日志:

image.png

日志最终我们可以看到,在绿色处,HorizontalScrollViewChild是有消费MOVE事件的,那之前不是讲错了吗?既然HorizontalScrollViewChild有消费MOVE事件,为啥ScrollViewParent还能滑动呢?因为在刚开始滑动的时候,滑动的距离还太小,因此ScrollViewParent的onInterceptTouchEvent还没有拦截这个事件,所以HorizontalScrollViewChild可以消费到MOVE事件。但后面一旦垂直滑动了一定距离,MOVE事件就会直接被ScrollViewParent消费掉,从而实现竖直滑动。我们可以看ScrollViewParent的源码佐证一下:

final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
    mIsBeingDragged = true;
    mLastMotionY = y;
    initVelocityTrackerIfNotExists();
    mVelocityTracker.addMovement(ev);
    mNestedYOffset = 0;
    if (mScrollStrictSpan == null) {
        mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
    }
    final ViewParent parent = getParent();
    if (parent != null) {
        parent.requestDisallowInterceptTouchEvent(true);
    }
}

可以看到第3行,只有当yDiff大于一定滑动距离即mTouchSlop时,才会被认定为在垂直方向上滑动,将mIsBeingDragged设置为true。若我们水平滑动HorizontalScrollViewChild,可以断定,因为yDiff是没有超过mTouchSlop的,所以HorizontalScrollViewChild就可以正常滑动了。观察日志:

image.png

可以看到,水平滑动HorizontalScrollViewChild时,ScrollViewParent没有在onInterceptTouchEvent拦截MOVE事件,MOVE事件得以顺利被HorizontalScrollViewChild消费,实现水平滑动。

那看样子,两个ScrollView并不冲突呀,他们都已经写好内部逻辑了。其实不然。假设我们现在手指放在HorizontalScrollViewChild区域中,滑斜上方45角度向左/向右滑动,这时候我们就会发现,有时候我们滑动的是HorizontalScrollViewChild,有时候我们滑动的却是ScrollViewParent,这就是典型的不同方向上的滑动冲突。

通用解决方案

一般情况下,我们有“内部拦截法”和“外部拦截法”两种解决方案去处理常见的滑动冲突。

外部拦截法

下面我们用外部拦截法来处理上面的滑动冲突。外部拦截法,指的是从外部容器入手,去决定是否要去拦截事件,若拦截掉,子View就没法消费了。现在为了处理斜方向的滑动冲突,我们可以简单地做一个逻辑:当在竖直方向滑动超过15像素时,我们就认为是滑动外部容器ScrollViewParent。代码如下:

public class ScrollViewParent extends ScrollView {

    public ScrollViewParent(Context context) {
        super(context);
    }

    public ScrollViewParent(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public ScrollViewParent(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    private float downX;
    private float downY;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "ScrollViewParent dispatchTouchEvent");
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = ev.getX();
                downY = ev.getY();
        }
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "ScrollViewParent onInterceptTouchEvent. deltaY:" + (ev.getY() - downY));
        if (ev.getAction() == MotionEvent.ACTION_MOVE) {
            return Math.abs(ev.getY() - downY) > 15;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "ScrollViewParent onTouchEvent");
        return super.onTouchEvent(ev);
    }
}

代码32-33行实现了MOVE事件的拦截逻辑。然后观察日志:

image.png 红框内的日志,可以看到此时ScrollViewParent会拦截MOVE事件,直接分发给自己的onTouchEvent,后续的MOVE事件也会直接到onTouchEvent事件去。HorizontalScrollViewChild将收不到MOVE事件。实现效果上也符合我们的预期,当我们斜方向滑动时,滑动的大概率(除非dy足够小)都是外部容器ScrollViewParent了。

当然,这其实是我们假定的一个处理冲突的逻辑,真实的产品逻辑需要根据业务情况去调整。比如当竖直滑动速度超过xx时,滑动外部容器;或者当HorizontalScrollViewChild内部某个View可见时,滑动外部容器,都有可能,但万变不离其宗,最终都是通过改变事件分发的路径去实现。

内部拦截法

下面再讲“内部拦截法”怎么处理滑动冲突。“内部拦截法”跟“外部拦截法”相反,是从内部容器出发去解决冲突。这依赖于ViewParent#requestDisallowInterceptTouchEvent(),看其源码的注释我们很容易知道它是什么意思:

Called when a child does not want this parent and its ancestors to intercept touch events with ViewGroup.onInterceptTouchEvent(MotionEvent).
This parent should pass this call onto its parents. This parent must obey this request for the duration of the touch (that is, only clear the flag after this parent has received an up or a cancel.
Params:
disallowIntercept – True if the child does not want the parent to intercept touch events.

public void requestDisallowInterceptTouchEvent(boolean disallowIntercept);

当子类不想让其父类/祖先ViewGroup.onInterceptTouchEvent(MotionEvent)方法时,可以调用requestDisallowInterceptTouchEvent(),从而保证父类/祖先无法通过ViewGroup.onInterceptTouchEvent(MotionEvent)方法对事件进行拦截。且一旦设置了这个flag,那么这次事件序列中后续的所有事件,都不会经过父类/祖先的onInterceptTouchEvent(MotionEvent)方法了。直到下次触摸发生,才会清楚掉这个flag。

我们可以直接看源码,看看这是怎么起作用的。requestDisallowInterceptTouchEvent实现:

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        // We're already in this state, assume our ancestors are too
        return;
    }

    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}

做的事情就是更改标记位,并递归访问父节点执行该方法。在View的dispatchTouchEvent方法中,对该标记位进行了判断:

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

核心代码是4-9行,当disallowIntercept结果为true时,就不会走onInterceptTouchEvent()方法了。

现在我们仍是以上面的ScrollViewParent和HorizontalScrollViewChild举例去解决滑动冲突。“内部”要求我们以内部容器的视角去考虑冲突,那么就假定当在水平方向滑动超过15像素时,滑动内部容器HorizontalScrollViewChild。这时候就需要这样编码:

public class HorizontalScrollViewChild extends HorizontalScrollView {

    public HorizontalScrollViewChild(Context context) {
        super(context);
    }

    public HorizontalScrollViewChild(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public HorizontalScrollViewChild(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    private float downX;
    private float downY;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "HorizontalScrollViewChild dispatchTouchEvent. deltaX:" + Math.abs(ev.getX() - downX));
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = ev.getX();
                downY = ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                if (Math.abs(ev.getX() - downX) > 15) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                } else {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
        }
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "HorizontalScrollViewChild onInterceptTouchEvent");
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        Log.i("touch_event_test", "HorizontalScrollViewChild onTouchEvent");
        return super.onTouchEvent(ev);
    }
}

在27行,调用了requestDisallowInterceptTouchEvent()方法。上面有说到,一旦标记位被设置为true,后续事件序列中的所有事件都不会调用了父类的onInterceptTouchEvent了。因此我们需要视情况将限制放开,如30行的写法就将flag重新设为false。另外还要注意,因为子View是DOWN事件的消费点,那么MOVE事件是不会经过子View的onInterceptTouchEvent方法的,所以在dispatchTouchEvent或者onTouchEvent中都可以设置标记位,唯独不能放到onInterceptTouchEvent方法;

现在我们斜方向滑动内部容器HorizontalScrollViewChild,观察日志:

image.png

可以看到红框区域,当dx大于15后,后续的MOVE事件就不会经过ScrollViewParent的onInterceptTouchEvent了,我们要的效果也就达到了。

小结

虽然上面只举了不同方向滑动冲突的例子,但不同方向冲突与同方向冲突,其最终处理的思路都是一样的,要么改变事件分发的路径,要么设置FLAG_DISALLOW_INTERCEPT标记位。它们在解决上的不同之处,就只在于“条件判断”

对于不同方向的冲突,我们给的条件判断可以是:当DX>X时,水平滑动、当DY>Y时,竖直滑动。

对于同方向的冲突,我们给的条件判断可以是:当滑动速度大于X时,滑动外部View,反之滑动内部View;当内部View已经滑到底了,才滑动外部View;当内部View中的某个子View不在屏幕中时,滑动外部View。

一般这些条件判断也都是基于各自的业务出发去进行选择,但只要我们知道通用的处理思路,问题就都可以迎刃而解了。至此,对滑动冲突的通用处理方法(“内部拦截法”与“外部拦截法”)就讲完了。下面进行个小结:

  • “外部拦截法”所使用的原理是运用事件分发机制,去改变事件分发的路径,拦截内部容器的事件。
  • “内部拦截法”使用的是requestDisallowInterceptTouchEvent()方法设置FLAG,不让父容器/祖先容器用onInterceptTouchEvent拦截方法。
  • 使用“内部拦截法”还是“外部拦截法”,首先需要去看实际业务需要我们怎么做,是从“内部”实现比较方便,还是从“外部”实现比较方便。
  • 相较于“外部拦截法”,“内部拦截法”并没有减少事件分发的层级,因此看起来可能会更加复杂一些。并且也需要注意requestDisallowInterceptTouchEvent方法具体在哪个方法中使用。若两个方法都能实现最终的效果,建议优先使用“外部拦截法”。

小技巧

这里再根据上文的事件分发机制,补充一个思路上的小技巧。观察某个View的三个方法日志时,如果有CANCEL事件,代表着上层容器肯定有拦截某个事件;如果onTouchEvent方法没有被调用,说明肯定有下层View消费掉了这个事件;如果onInterceptTouchEvent没被调用,却调用了onTouchEvent方法,说明下层有View调用了requestDisallowInterceptTouchEvent方法。

总结

这篇文章首先介绍了Android事件分发机制,通过Demo演示了各类事件是怎样分发的,并通过修改方法返回值演示如何事件拦截后的分发路径。在熟悉事件分发机制后,进而对常见的滑动冲突给出“内部拦截法”与“外部拦截法”两种通用解决方案。

如果你觉得这篇文章跟某篇博客或书籍中的内容很相似,不用怀疑,纯属不巧合(chao xi)。
因为我就是参考他们的内容去做的Demo,同时我也建议还不熟悉的同学也能自己做个Demo去加深这块知识的理解。

文章不足之处,还望大家多多海涵,多多指点,先行谢过!

参考文章

《Android开发艺术探索》——任玉刚

一步步探索学习Android Touch事件分发传递机制

解惑requestDisallowInterceptTouchEvent

补充更新

最近又遇到一个问题,明明在子view中已经调用了parent?.requestDisallowInterceptTouchEvent(true) 但是父view仍然拦截了事件。排查后发现action_down事件在子view没有拦截掉,仍然走的super,所以导致父view中firstTouchTarget仍然是空的,所以父view在dispatchTouchEvent的时候仍然会走onInterceptTouchEvent