Android事件分发 | ViewGroup分发事件

2,820 阅读7分钟

前言

在前面一篇文章我们说了如何把点击事件从Activity传递给ViewGroup的,以及它的2个方法重要方法dispatchTouchEvent以及onTouchEvent的调用时机和逻辑,那本章就接着说点击事件在ViewGroup中是如何处理的。

该系列文章链接:

Android事件分发 | Activity分发事件 - 掘金 (juejin.cn)

Android事件分发 | View分发事件 - 掘金 (juejin.cn)

正文

从前面Activity的思想来看,ViewGroup的传递思路也是责任链模式,一层一层进行向下传递,和Activity不同的是这里由3个方法配合完成。

三个方法

对于基本的事件处理Android开发者应该都比较熟悉了,所以我直接画个流程图来示意:

ViewGroup事件分发.png

这里其实当ViewGroup自己处理事件时,就是View的事件分发流程了,因为很简单,ViewGroup的父类就是View,所以这里可以直接调用super.dispatchTouchEvent方法,关于这部分内容,下篇文章View的事件分发细说。

dispatchTouchEvent

这个方法的代码很多,我们来截取主要的一段:

//拦截标志位
final boolean intercepted;
 //只有当这俩种情况才考虑要不要拦截事件
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {  //见分析1
        //见分析2
    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 {
    intercepted = true;
}

在上一篇文章中我们知道在dispatchTouchEvent方法中会调用onInterceptTouchEvent方法来判断是否要拦截事件,但是这个逻辑没有那么简单,上面代码就是其中的主要一段逻辑。

这里注意有个至关重要的点,就是当事件类型是 ACTION_DOWN 或者 mFirstTouchTarget != null 时才会去判断是否要拦截的主要逻辑。

所以当接收到 ACTION_DOWN事件时,不管什么情况都进行判断是否要拦截(只是判断是否要拦截,而不是要不要拦截)。

那另一个判断条件是啥呢?

分析1:mFirstTouchTarget != null

这里理解起来比较绕,主要就是为了实现一个功能。

当事件由ViewGroup的子元素成功处理时,mFirstTouchTarget会被赋值,并指向子元素。反过来说也就是当ViewGroup拦截事件时,mFirstTouchTarget就为null,那为什么要搞这一出呢?

假设现在有个滑动操作,我在GroupView A的View B上进行滑动,如下所示:

image.png

假如View B设计的需求是不能点击且无法滑动,所以要求在点击事件传递到GroupView A的时候就给拦截掉,这里一系类事件可以归纳为:

DOWN -> MOVE .... MOVE -> UP这个事件流,

所以看下面流程图:

onInterceptTouchEvent不会被调用多次.png

这里会发现当DOWN事件被onInterceptTouchEvent给拦截后,后面的MOVE和UP事件都会被它处理,因为由前面可知事件被拦截后mFirstTarget为null,所以这里的onInterceptTouchEvent不会再次被调用。

这里的代码就会产生一条真理,当ViewGroup拦截某个事件流的DOWN事件时,那这整个事件流都会给这个ViewGroup来处理,不会再往下进行分发,而且该ViewGroup的onInterceptTouchEvent将不会再被调用,这个很关键,后面有细说。

当DOWN事件没有被ViewGroup的onInterceptTouchEvent给拦截时,也就是返回false,这时后面的MOVE和UP事件还会进行判断,这时就有可能再次调用ViewGroup的onInterceptTouchEvent方法来看是否拦截事件了,至于如何调用以及时机,我们继续说。

分析2:disallowIntercept标志位

上面代码其实很符合一般的逻辑,不过也有一些问题需要处理,上面代码无法处理,就比如下面这个例子:

image.png

这里GroupView A是一个竖直方向可以滚动的View,同时View B是一个竖直方向可以滚动显示内容的View,这里就可以把View B看成RecyclerView,这时我向上滑动B,这时内容进行位移:

image.png

当已经到底的时候,也就是条目12是最后一条,这时再向上滑动,应该让整个View B向上滑动,即下面效果:

image.png

这时MOVE事件将由ViewGroup A进行拦截处理。

这里就涉及到了Android事件分发的重要知识点:滑动冲突。

滑动冲突

其实这里就涉及到了滑动冲突的问题,不过我想说的是凡是看问题我们要看其本质。

比如这里的问题,如果我们按照前面流程图来说的话,我们是可以解决的,也就是我们常说的外部拦截法。

下面来看解决办法

外部拦截法
  • 首先是DOWN事件传递到A时,这时是不能进行拦截的,按照前面所说,如果DOWN事件被A拦截,它后面的事件流将都会被A处理。
  • 接着是MOVE事件传递到A时,按照前面代码流程,这时mFirstTouchTarget不为空,将进入判断,这时再判断B是否滑动到了顶部或者底部,如果滑动到了顶部或者顶部,则由A把MOVE事件给拦截,A进行处理,否则不拦截,MOVE事件传递给B,由B继续处理。

这里的外部拦截法其实比较好理解,注意点就是不能拦截DOWN和获取B的状态,所以这里有个局限性,也就是A的事件分发必须处理,B的也要处理,有点麻烦。

内部拦截法

那有没有一个更方便的做法呢,比如我这里的A就是一个竖向的ScrollView,B是我自定义的View,我只需要修改B就可以达到这个效果,当然有,这就是内部拦截法。

内部拦截法的关键就是这个disallowIntercept标志位,所以我们先来看看这个标志位是啥。

//ViewGroup中的flag
protected int mGroupFlags;

给它赋值的方法通常由它的子View来赋值:

//通常由子类来调用
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    //true -> FLAG_DISALLOW_INTERCEPT
    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }
    //递归调用
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}

然后是使用的地方,也就是之前说的dispatchTouchEvent方法中:

//dispatchTouchEvent关键逻辑代码
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    //这里默认为false,也就是上面方法为false
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        //当调用requestDisallowInterceptTouchEvent为false时
        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;
}

关于这个flag有很多叫法,我个人喜欢叫做 是否禁用事件拦截功能 ,默认是false,也就是不禁用。

  • 当requestDisallowInterceptTouchEvent方法设置为false时,也就是不禁用事件拦截功能,这时包括DOWN、MOVE事件都会在ViewGroup中进行判断是否拦截,比如例子中的ViewGroup A它会默认拦截竖直方向的MOVE事件,如果不禁用事件拦截功能,它将会拦截MOVE事件。

  • 当requestDisallowInterceptTouchEvent方法设置为true时,说明禁用事件拦截功能,这时不论什么事件都不进行拦截,传递给其子View处理

内部拦截法原理总结

所以这里的disallowIntercept这个标志位 是否禁用事件拦截功能 非常重要。下面来小小总结一下:

  1. 当你自定义View时,无法修改其包裹View的事件拦截代码时,需要使用内部拦截法。

  2. 对于DOWN事件,其包裹View不能进行拦截,前面也是说了,假如把DOWN拦截了,后面子View将永远不会得到剩下事件流。

  3. 对于MOVE事件,当需要自己处理时,则禁用包裹View的事件拦截功能。当不需要自己处理时,则不禁用包裹View的事件拦截功能。

兜底处理

当然和前面的Activity一样,当ViewGroup的子View没有成功消费掉事件,或者点击的ViewGroup的空白地方,这时就会有兜底策略,会调用super.dispatchTouchEvent方法,也就是View的事件分发机制来处理,至于一直没说的onTouchEvent方法,我们会放在View分发事件中来说。

总结

这篇内容大多数作为Android开发都非常熟悉了,不过温故而知新,还是总结几点:

  • 根据源码mFirstTouchTarget的逻辑,当ViewGroup拦截DOWN事件时,后续事件流都由它处理

  • ViewGroup的onInterceptTouchEvent方法默认不拦截DOWN事件,视需求情况拦截MOVE事件,一般情况所有事件都会在这里判断是否拦截

  • 当无法修改包裹View的事件拦截时,可以通过是否 禁用ViewGroup的事件拦截功能 来实现内部拦截法,来让ViewGroup根据子View来分发事件。

  • 当ViewGroup的子View没有消费事件或者点击空白处,由ViewGroup的父类View自己处理