【总结】事件分发机制

通常,开发人员所涉及到的事件分发机制涉及到了如下几个方法

  1. dispatchTouchEvent(MotionEvent ev)
    用来进行事件分发。如果事件能传递给当前的View,那么此方法一定会被调用。

  2. onInterceptTouchEvent(MotionEvent ev)
    用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会再被调用。

  3. onTouchEvent(MotionEvent ev)
    用来处理触摸事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接受事件。

  4. onTouch(View view, MotionEvent motionEvent)
    用于处理触摸事件,通过setOnTouchListener设置,很常见的方法。

  5. onClick(View view)
    用于处理点击事件,通过setOnClickListener设置,很常见的方法。

  6. requestDisallowInterceptTouchEvent(boolean b)
    请求不拦截触摸事件。一般用于处理滑动冲突中,子控件请求父控件不拦截ACTION_DOWN以外的其他事件,ACTION_DOWN事件不受影响。

本文会围绕以上几个方法,从现象的角度,对安卓的事件分发机制做出简单解释。

事件分发逻辑

先看一张图,图1为默认情况下,系统事件分发逻辑。
图片均无打码标记,欢迎下载留存参考。ヾ(≧O≦)〃

图1 事件分发逻辑图.png

上图为ACTION_DOWN事件的分发过程,ACTION_MOVE、ACTION_UP事件分发过程略有差别,稍后补充。
消费、拦截,在讲事件分发机制的时候我们通常会使用这两个词来表明,一个触摸事件在经过某个控件的时候停止了传递。

对一个触摸事件来说,一个完整分发通常包含三个部分,一个Activity,至少一个ViewGroup,至多一个View。Activity是事件分发的起点,如果所点击位置的所有控件都不对触摸事件进行拦截(即图1中的7个方法都返回super),一个触摸事件在经过一个周期的分发之后,又会回到Activity,最终被Activity消费掉。

第二张图是为View(ViewGroup)添加了onTouchListeneronClickListener两个方法之后,图1中,onTouchEvent部分的处理逻辑。

图2 onTouchEvent内部逻辑.png

看图我们可以知道,onTouchEvent方法的优先级低于onTouch方法,而高于onClick系列方法。

就是说如果为一个View(ViewGroup)通过setOnTouchListener方法设置了onTouch方法,并且在onTouch方法中返回true将触摸事件消费掉的话,onTouchEvent便无法对触摸事件做出正确响应。

而如果onTouchEvent方法将事件消费掉(返回true),或者主动选择不对触摸事件做出响应(返回false),这两种情况下,无论setOnClickListener设置与否,onClick方法都不会被执行。只有onTouchEvent返回时调用super方法,并且setOnClickListener成功设置,onClick方法才会被执行。
需要注意的是,onClick虽然没有返回值,但是它会默认拦截掉点击事件。

一些结论

为方便说明,将图1稍加修改,并添加标记重新贴出,见图3。垂直顺序代表事件传递顺序。
这张图在下文用的比较多,建议点击这里在新标签中打开然后对比观看。

图3 添加标记稍作修改的图1.png
1. 事件序列

是指手指从接触屏幕的那一刻开始,到手指离开屏幕的那一刻结束,整个过程所产生的一系列事件,这个序列从ACTION_DOWN开始,包括多个ACTION_MOVE,以ACTION_UP结束。

2. 事件传递会以最短路径传递
  1. 若一个View(ViewGroup)的dispatchTouchEvent方法拦截了一个事件序列的全部事件,那么这个事件的事件序列都会按照ACTION_DOWN的传递路径传递,最终被消耗掉。
    例如:标号为4的dispatchTouchEvent返回值返回了true,拦截所有事件
    ACTION_DOWN、ACTION_MOVE、ACTION_UP的传递路径均为 1-2-3-4-消费。

  2. 若一个View(ViewGroup)的onTouchEvent方法拦截了一个事件序列的全部事件,那么这个事件的事件序列的的ACTION_DOWN和ACTION_MOVE、ACTION_UP会沿着不同路径传递,当前View的onInterceptTouchEvent方法(如果有)会被后两者忽略。同一个事件序列中的ACTION_MOVE、ACTION_UP会直接从dispatchTouchEvent传递给onTouchEvent。
    例如:标号为8的onTouchEvent返回值返回了true,拦截所有事件
    ACTION_DOWN方法的传递路径为 1-2-3-4-5-6-7-8-消费
    而ACTION_MOVE、ACTION_UP的路径会变为 1-2-3-4-8-消费。

消耗ACTION_DOWN的View就是事件传递的终点,事件序列中的后续事件不一定会沿着ACTION_DOWN的传递路径传递,而会找到一条最短路径传递,一条从事件分发开始到被拦截的最短路径。
一个View(ViewGroup)一旦决定拦截(消费了ACTION_DOWN事件),那么他的onInterceptTouchEvent方法不会在被调用。
PS:这里不要认为是忽略所有的onInterceptTouchEvent,只是当前View的onInterceptTouchEvent方法会被忽略,父容器的onInterceptTouchEvent照常执行。这对于理解requestDisallowInterceptTouchEvent方法的处理逻辑很重要。

3. 拦截ACTION_DOWN的View通常是事件传递的终点

若在一个View(ViewGroup)中,一个事件序列的ACTION_DOWN事件比ACTION_MOVE、ACTION_UP更早的被拦截,那么后两个事件会比ACTION_DOWN传递更多的方法。
例如:标号为4的dispatchTouchEvent,当事件为ACTION_DOWN的时候,返回值为true,其他为默认的super。标号为8的onTouchEvent返回值返回了true,拦截所有事件。
ACTION_DOWN方法的传递路径为 1-2-3-4-消费
而ACTION_MOVE、ACTION_UP的路径会变为 1-2-3-4-8-消费。

这条结论作为上一条的补充
消耗ACTION_DOWN的View就是事件传递的终点,但消耗ACTION_DOWN的方法不一定是事件传递的终点。如果没有被拦截,事件序列总是会传递到onTouchEvent方法中。
但通常不会在dispatchTouchEvent拦截ACTION_DOWN事件,这会让滑动冲突无法处理。

4. 只拦截ACTION_DOWN事件,其余事件会消失并被Activity消耗

若在一个View(ViewGroup)中,只拦截事件序列的ACTION_DOWN事件,那么ACTION_MOVE、ACTION_UP会消失,并且最终呗Activity消耗。
假设ViewGroup2,标号为4的dispatchTouchEvent拦截了一个事件序列的ACTION_DOWN事件,但ViewGroup2却没有拦截这个事件序列的ACTION_MOVE、ACTION_UP事件,那么这次事件传递会出现跨越的情况。
我们将标号为4的dispatchTouchEvent,当事件为ACTION_DOWN的时候,返回值设为true,其他为默认的super。
ACTION_DOWN方法的传递路径为 1-2-3-4-消费
ACTION_MOVE、ACTION_UP的路径会变为 1-2-3-4-8-10-消费(ACTION_DOWN以外的事件在传递到8的时候消失,9被跨越了)

只拦截ACTION_DOWN事件,其余事件会在拦截ACTION_DOWN的View中传递完成后消失,并最终被Activity消耗。

5. 拦截一个事件必须拦截它的ACTION_DOWN

若要一个View(ViewGroup)要拦截一个事件序列,当前View(ViewGroup)或者其子View(ViewGroup)必须拦截其ACTION_DOWN事件。
由第2条可知,ACTION_DOWN总会为事件序列找到一条最短路径,去传递整个事件序列中的点击事件,若拦截到了ACTION_DOWN,那么当前View也可以拦截到这个事件序列中的其他事件。

那么假设ViewGroup2想拦截触摸事件中的ACTION_MOVE事件,但却没有拦截事件序列中的ACTION_DOWN事件,看看会发生什么情况。
例如:ViewGroup2中,标号为8的onTouchEvent想要拦截触摸事件中的ACTION_MOVE事件,但却没有拦截事件序列中的ACTION_DOWN事件。
我们将标号为4的dispatchTouchEvent,当事件为ACTION_MOVE的时候,返回值为true,其他为默认的super
ACTION_DOWN方法的传递路径为 1-2-3-4-5-6-7-8-9-10-消费
ACTION_MOVE、ACTION_UP的路径会变为 1-10-消费(最短路径会忽略ViewGroup2)

一个View若要拦截一个事件序列中的事件,必须拦截其ACTION_DOWN事件。

6. 触摸事件可能会被“半路打劫”

一个事件序列中ACTION_DOWN以外的事件,可能会在传递到消耗ACTION_DOWN的View之前被提前消耗掉。

稍稍修改下第5条的情况,假设,ViewGroup2想拦截触摸事件中的ACTION_MOVE事件,也没有拦截事件序列中的ACTION_DOWN事件,但View会拦截触摸事件中的ACTION_MOVE事件。
我们将标号为4的dispatchTouchEvent,当事件为ACTION_MOVE的时候,返回值设为true,其他为默认的super。我们将标号为7的onTouchEvent返回值设为true,拦截所有事件。
ACTION_DOWN方法的传递路径为 1-2-3-4-5-6-7-消费
ACTION_MOVE的路径会变为 1-2-3-4-消费(原本MOVE事件也会传到7,但是此时被提前拦截消耗)
ACTION_UP方法的传递路径为 1-2-3-4-5-6-7-消费

一个View是事件传递的终点,也有可能无法拦截到一个事件序列中ACTION_DOWN以外的事件,因为他们在传递的过程中,被提前消耗了。

7. 还有几条
  1. ViewGroup默认不拦截任何事件,ViewGroup的onInterceptTouchEvent默认返回false

  2. View没有onInterceptTouchEvent方法,默认情况下一旦有点击事件传递给他,他的onTouchEvent方法就会被调用

  3. 可点击的View的onTouchEvent默认都是会消耗事件(他们的clickable属性默认都是true),比如Button。
    若不想让View消耗事件,需要将他们的clickable,longClickable属性设为false。

  4. 事件传递的过程都是由外向内的,即事件总是线传递给父元素,然后再由父元素分发给子View。但是 requestDisallowInterceptTouchEvent可以干预这个过程,这一部分在滑动冲突讲。

  5. 只要点击事件传递到了当前的View(ViewGroup),他的dispatchTouchEvent一定会被调用。

滑动冲突

我们常说的滑动冲突,一般在多个需要消费滑动手势的控件嵌套的时候出现。
现阶段安卓控件滑动模式无非就两种,横向滑动与纵向滑动,所以总结起来只有两种情况,如图4。

图4 滑动冲突.png

第一种:图4左,内部外部控件滑动方向不一致。
第二种,图4右,内部外部控件滑动方向一致。
更多层嵌套的滑动冲突可以两两拆分成上面这两种情况,所以不表。

第一种情况主要出现在ViewPager跟ScrollView嵌套所组成的页面滑动效果,主流应用几乎都会使用这个效果。
需要注意的是,这种情况下,本来是有滑动冲突的,但现在大多数滑动控件,自身对这种情况的处理都比较理想,不需要额外处理就可以达到满意的结果。

第二种情况也时常能看到,比如两个ViewPager嵌套。很多应用首页顶部有一个Banner,还可以通过左右滑动切换Tab,就会出现这种冲突。

从本质上来讲,这两种情况的滑动冲突是相似的,仅仅是滑动策略不同,解决思路基本一致。

处理滑动冲突的思路

对于第一种情况,他的处理规则是,当用户左右滑动的时候,需要让外部View拦截这个触摸事件,当用户上下滑动的时候,让内部拦截这个触摸事件。判断一次滑动是水平滑动还是垂直滑动,根据滑动过程中,两次ACTION_MOVE事件的坐标点之间的坐标就可以得出。

对于第二种情况,无法从滑动方式上作出判断,只能从业务逻辑上找办法,比如一个是首页的Banner,一个是首页的Tab页面。我们的处理方法是,当触摸到Banner开始滑动的时候,触摸事件让Banner拦截,当触摸到Banner以外部分开始滑动的时候,让外层的ViewPage拦截。

处理滑动冲突的办法

滑动冲突常规的处理方法有两种

第一种是通过外层View处理拦截规则,将拦截逻辑写在外层View中。就是说如果父容器需要这个事件的时候就拦截当前事件,如果不需要就不拦截,让这个事件继续向下传递,子控件自然能接受并拦截这个事件。或者让父容器判断什么时候子控件需要这个触摸事件,就不拦截这个事件交给子控件拦截,当子控件不需要这个触摸事件的时候,父容器再去拦截这个事件。
这种办法的优势是符合触摸事件的分发机制,代码逻辑上更加清晰。

第二种方法是通过requestDisallowInterceptTouchEvent(true)方法,在事件传递上忽略付容器的onInterceptTouchEvent方法,把所有事件都传递给子控件,在子控件内部进行逻辑处理。如果子控件需要这个事件就直接消耗掉,否则再交给父容器处理。
这种方式的优势是更加灵活,思路清晰,但在代码逻辑上可能没有上一种好。

事件冲突产生原因以及requestDisallowInterceptTouchEvent的生效原理

第一种方法符合事件分发机制,此处不再赘述。
此处着重写一下第二种方式。

这里还要再看一次图3
ViewGroup1视作外层ViewGroup
ViewGroup2视作内层ViewGroup
View视作内层ViewGroup中的元素

我们让ViewGroup1标号为3的onInterceptTouchEvent方法,在触摸事件为ACTION_MOVE的时候返回true,其他情况返回super。标号为9的onTouchEvent方法返回true,拦截所有事件。
这个时候我们看一下触摸事件的传递路径
ACTION_DOWN的传递路径为1-2-3-4-5-6-7-8-9-消耗
ACTION_MOVE,ACTION_UP的传递路径为1-2-9-消耗(沿着最短路径传递)

这时候ViewGroup2不在事件传递路径上,无法拦截触摸事件。
现在我们让ViewGroup2标号为8的onTouchEvent方法返回true,拦截所有事件。
这个时候我们看一下触摸事件的传递路径
ACTION_DOWN的传递路径为1-2-3-4-5-6-7-8-消耗
ACTION_MOVE的传递方式与上文均不太一样,它在传递到4的时候,4并没有接收到ACTION_MOVE事件,反而接收到了一个ACTION_CANCEL事件。
所以此时ACTION_MOVE、ACTION_UP的传递路径为1-2-3-消耗
出现这种不同的原因是, ViewGroup1标号为3的onInterceptTouchEvent方法拦截了事件序列中的ACTION_MOVE方法,将其直接分发给了标号为9的onTouchEvent方法。虽然当ViewGroup1为事件传递的终点的时候,标号为3的onInterceptTouchEvent方法会被忽略,不会执行,但是由于ViewGroup2标号为8的onTouchEvent方法返回true,此时ViewGroup2成为了事件传递的终点,标号为3的onInterceptTouchEvent方法又会被执行。这个方法执行的结果是将ACTION_MOVE事件直接分发给了标号为9的onTouchEvent方法。

这个时候,则产生了所谓的滑动冲突,即ViewGroup2拦截了ACTION_DOWN事件,却没有得到这个事件序列中的其他事件。

那么该如何处理这种情况,就需要用到requestDisallowInterceptTouchEvent方法了。
现在我们在ViewGroup2标号为4的dispatchTouchEvent方法中写入getParent().requestDisallowInterceptTouchEvent(true),请求父容器不拦截ACTION_DOWN以外的触摸事件。(这个方法的实质是,传入参数为true时屏蔽父容器的onInterceptTouchEvent方法。)
这个时候我们看一下触摸事件的传递路径
ACTION_DOWN的传递路径为1-2-3-4-5-6-7-8-消耗
ACTION_MOVE,ACTION_UP的传递路径为1-2-4-8-消耗
我们看到ACTION_MOVE,ACTION_UP这两个方法跨过了标号为3的onInterceptTouchEvent方法,因为它被屏蔽了。

这样,ViewGroup2又可以拿到事件序列中的其他事件,滑动冲突得以解决。

requestDisallowInterceptTouchEvent的生效范围

还是上一个例子
我们让ViewGroup1标号为3的onInterceptTouchEvent方法,在触摸事件为ACTION_MOVE的时候返回true,其他情况返回super。标号为9的onTouchEvent方法返回true,拦截所有事件。
我们知道这个触摸事件的传递路径为
ACTION_DOWN的传递路径为1-2-3-4-5-6-7-8-9-消耗
ACTION_MOVE,ACTION_UP的传递路径为1-2-9-消耗(沿着最短路径传递)

这次我们不对ViewGroup2做任何更改,使用其默认返回值。
设置View标号为7的onTouchEvent方法返回true,拦截所有事件。在View标号为6的dispatchTouchEvent方法中写入getParent().requestDisallowInterceptTouchEvent(true)
这个时候我们看一下触摸事件的传递路径
ACTION_DOWN的传递路径为1-2-3-4-5-6-7-消耗
ACTION_MOVE,ACTION_UP的传递路径为1-2-4-6-7-消耗(没有3、5,连跳两级)

设置requestDisallowInterceptTouchEvent(true)可以忽略整条事件传递路径上的所有onInterceptTouchEvent方法。

为什么会影响所有的,究其原因的话,是因为源码是这么实现的


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

另外因为这个原因,如果事件传递终点的View是一个ViewGroup的话(如上上个例子中的ViewGroup2),写getParent().requestDisallowInterceptTouchEvent(true)或者写requestDisallowInterceptTouchEvent(true)效果都是一样的,都会对路径上的所有onInterceptTouchEvent方法产生影响。

这篇文的主要内容,到这里差不多就结束了,不过还有几个不是很重要的点,以及最下方还有滑动冲突的代码实现。

需要注意的几个问题

虽然requestDisallowInterceptTouchEvent写在哪里都可以生效,但我们习惯写在dispatchTouchEvent方法中,毕竟它负责事件分发。

处理滑动冲突时候,父容器的dispatchTouchEvent不能拦截ACTION_DOWN事件,否则子控件连requestDisallowInterceptTouchEvent的机会都没有,因为子控件没有拦截到ACTION_DOWN事件,其他事件不会交给它处理。我们在书写自定义控件的时候,一般会在onTouchEvent方法中拦截ACTION_DOWN事件。

requestDisallowInterceptTouchEvent方法是通过改变FLAG_DISALLOW_INTERCEPT标志位来影响事件分发的,传递ACTION_DOWN事件的时候,标志位无法影响onInterceptTouchEvent方法,所以requestDisallowInterceptTouchEvent方法只能请求到ACTION_DOWN以外的事件。onInterceptTouchEvent不应拦截ACTION_DOWN事件。

处理滑动冲突的代码实现

以下是两种处理方式的伪代码,其实如果上文好好看的话,自己写出来问题不大,此处贴出来以供大家参考。使用的时候稍作修改就能满足大部分需求。

  1. 通过外层View处理拦截逻辑

    @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:
                intercepted = false;
                break;
            case MotionEvent.ACTION_MOVE:
                if (父容器需要这个点击事件)
                    intercepted = true;
                else
                    intercepted = false;
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
            default:
                break;
        }
        mLastXintercept = x;
        mLastYintercept = Y;
        return intercepted;
    }

  1. 通过内层View处理拦截逻辑,即通过requestDisallowInterceptTouchEvent(true)方法。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mlastX;
                int deltaY = y - mlastY;
                if (父容器需要这个点击事件)
                    getParent().requestDisallowInterceptTouchEvent(false);
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
        mlastX = x;
        mlastY = Y;
        return super.dispatchTouchEvent(ev);
    }


对了,画图用的是Visio 2013版,作图简直不要太好用。
个人理解,难免有错误纰漏,欢迎指正。转载请注明出处。

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

推荐阅读更多精彩内容