Android的View事件分发机制

了解Activity的构成

一个Activity包含了一个Window对象,这个对象是由PhoneWindow来实现的。PhoneWindow将DecorView作为整个应用窗口的根View,而这个DecorView又将屏幕划分为两个区域:一个是TitleView,另一个是ContentView,而我们平时所写的就是展示在ContentView中的。

触摸事件的类型

触摸事件对应的是MotionEvent类,事件的类型主要有如下三种:

  • ACTION_DOWN
  • ACTION_MOVE(移动的距离超过一定的阈值会被判定为ACTION_MOVE操作)
  • ACTION_UP

View事件分发本质就是对MotionEvent事件分发的过程。即当一个MotionEvent发生后,系统将这个点击事件传递到一个具体的View上。

事件分发流程

image.png

事件分发过程由三个方法共同完成:

  1. dispatchTouchEvent:如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受当前View的onTouchEvent和下级View的dispatchTouchEvent()方法的影响,表示是否消耗当前事件。方法返回值为true表示事件被当前视图消费掉;返回为super.dispatchTouchEvent表示继续分发该事件,返回为false表示交给父类的onTouchEvent处理。
  2. onInterceptTouchEvent:在dispatchTouchEvent()方法内部调用,用于判断是否拦截某个事件,返回true表示拦截当前事件并交由自身的onTouchEvent方法进行消费。如果当前view拦截了某个事件,那么在同一个事件序列中,此方法不会再次被调用。
    返回false表示不拦截,需要继续传递给子视图。如果return super.onInterceptTouchEvent(ev), 事件拦截分两种情况:
  • 如果该View存在子View且点击到了该子View, 则不拦截, 继续分发 给子View 处理, 此时相当于return false。
  • 如果该View没有子View或者有子View但是没有点击中子View(此时ViewGroup 相当于普通View), 则交由该View的onTouchEvent响应,此时相当于return true。
  1. onTouchEvent():同样在dispatchTouchEvent()方法内部调用,用来处理事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前view无法再次接收到事件。
    方法返回值为true表示当前视图可以处理对应的事件;返回值为false表示当前视图不处理这个事件,它会被传递给父视图的onTouchEvent方法进行处理。如果return super.onTouchEvent(ev),事件处理分为两种情况:
  • 如果该View是clickable或者longclickable的,则会返回true, 表示消费 了该事件, 与返回true一样;
  • 如果该View不是clickable或者longclickable的,则会返回false, 表示不 消费该事件,将会向上传递,与返回false一样。

三个方法的关系用伪代码表示如下:

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

点击事件的传递规则:对于一个根ViewGroup来说,点击事件产生后,首先会传递给它,这是它的dispatchTouchEvent就会被调用,如果这个ViewGroup的onInterceptTouchEvent方法返回true就表示它要拦截当前事件,接着事件就会交给这个ViewGroup处理,这时如果它的mOnTouchListener被设置,则onTouch会被调用,如果onTouch返回false,那么onTouchEvent会被调用,如果onTouch返回true,那么onTouchEvent不会被调用。在onTouchEvent中,如果设置了mOnCLickListener,则onClick会被调用。只要View的CLICKABLE和LONG_CLICKABLE有一个为true,onTouchEvent()就会返回true消耗这个事件。如果这个ViewGroup的onInterceptTouchEvent方法返回false就表示它不拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的dispatchTouchEvent方法就会被调用,如此反复直到事件被最终处理。

重要结论

  • 事件传递优先级:onTouchListener.onTouch > onTouchEvent > onClickListener.onClick。

  • 正常情况下,一个事件序列只能被一个View拦截且消耗。因为一旦一个元素拦截了此事件,那么同一个事件序列内的所有事件都会直接交给它处理(即不会再调用这个View的拦截方法去询问它是否要拦截了,而是把剩余的ACTION_MOVE、ACTION_DOWN等事件直接交给它来处理)。特例:通过将重写View的onTouchEvent返回false可强行将事件转交给其他View处理。

  • 某个view一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent返回了false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交它的父元素去处理。意思是事件一旦交给来一个view处理,那么它必须消耗掉,否则同一事件序列中剩下的事件就不会再交给它来处理了

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

  • ViewGroup默认不拦截任何事件(返回false)。

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

  • View的enable属性不影响onTouchEvent的默认返回值。哪怕一个view是disable的,只要它的clickable或longclickable有一个为true,那么它的onTouchEvent就返回true。

  • 通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN事件除外。

源码分析

  1. 当一个点击事件发生时,事件最先传递给当前Activity,由Activity的dispatchTouchEvent进行事件派发,具体是由Activity内部的Window(具体实现是PhoneWindow)来完成。Window会将事件传递给DecorView(一般是当前界面的底层容器,即setContentView所设置的View的父容器)

Activity的dispatchTouchEvent


image.png

PhoneWindow的dispatchTouchEvent


image.png
  1. ViewGroup的dispatchTouchEvent处理逻辑


    image.png

从上面的代码可以看出,在两种情况下会判断是否要拦截当前事件:事件类型为ACTION_DOWN(点击事件序列的第一个事件)和mFirstTouchTarget!=null,(什么时候非空呢?当事件由viewGroup的子元素成功处理时,mFirstTouchTarget会被赋值,即viewGroup不拦截并交给子元素处理时非空,反之拦截则为空,那么后面的同序列事件都不会调用到onInterceptTouchEvent,而是直接默认拦截),说明onInterceptTouchEvent不是每次都会调用,如果想提前处理所有点击事件,要选择dispatchTouchEvent。

FLAG_DISALLOW_INTERCEPT标记位,可通过子view的requestDisallowInterceptTouchEvent方法来设置,后面滑动冲突会说到

  1. 当ViewGroup不拦截事件的情况下,事件hi向下分发由它的子view进行处理,遍历ViewGroup所有子元素,然后判断子元素是否能否接收到点击事件。


    image.png

    image.png

dispatchTransformedTouchEvent这个方法实际调用的是子元素的dispatchTouchEvent方法,这样事件就交给子元素处理了,在
addTouchTarget()方法中会前面说到的mFirstTouchTarget赋值

  1. 如果遍历所有子元素后事件都没有被处理(没有子元素,或者子元素处理了但dispatchTouchEvent返回false(一般是因为onTouchEvent返回false)),这种情况下ViewGroup会自己处理点击事件。由上面代码可知这里会转到View的dispatchTouchEvent方法,即交给View来处理
image.png

dispatchTransformedTouchEvent

![image]
image.png
  1. View对点击事件的处理比较简单,它没有子元素向下传递只能自己处理

先看dispatchTouchEvent方法


image.png

会先判断有没有设置OnTouchListener,如果OnTouchListener的onTouch返回true,那么onTouchEvent不会被调用,可见onTouch的优先级比onTouchEvent高

接下来看onTouchEvent


image.png

可以看到Disabled状态下的View同样会消耗点击事件

再看onTouchEvent中对点击事件的具体处理

image.png

只要CLICKABLE或者LONG_CLICKABLE一个为true,那么都会消耗 这个事件,即onTouchEvent方法返回true,而不管是否是DISABLE。

当ACTION_UP 事件发生时,会触发performClick方法,如果View设置来OnClickListener,那么performClick方法内部会调用它的onClick方法。
由此也可以知道几个方法调用的优先级onTouch > onTouchEvent > onClick

问题

  1. ACTION_CANCEL什么时候触发,触摸button然后滑动到外部抬起会触发点击事件吗,再滑动回去抬起会么?
  • 一般ACTION_CANCEL和ACTION_UP都作为View一段事件处理的结束。如果在父View中拦截ACTION_UP或ACTION_MOVE,在第一次父视图拦截消息的瞬间,父视图指定子视图不接受后续消息了,同时子视图会收到ACTION_CANCEL事件。
  • 如果触摸某个控件,但是又不是在这个控件的区域上抬起(移动到别的地方了),就会出现action_cancel。
  1. 点击事件被拦截,但是想传到下面的View,如何操作?

重写子类的requestDisallowInterceptTouchEvent()方法返回true就不会执行父类的onInterceptTouchEvent(),即可将点击事件传到下面的View。

  1. ViewGroup分发时会遍历ViewGroup的子元素,有多个子元素如何判断那个子元素接收点击事件?

在ViewGroup的分发方法中有一段逻辑:首先遍历ViewGroup的所有子元素,然后判断子元素是否能够接收到点击事件。是否能够接收由两点来衡量:子元素是否在播放动画和点击事件的坐标是否落在子元素的区域内。(没有在播放动画且坐标落在子元素区域内则分发给子元素)。遍历过程中如果某个子元素的dispatchTouchEvent返回true,则交给子元素处理并跳出循环。如果返回false则继续分发给下一个子元素(如果有的话)或自己处理(没有子元素或者子元素处理了,但是在dispatchTouchEvent返回了false)

滑动冲突

常见滑动冲突场景可分为以下3种:

  1. 外部滑动方向和内部滑动方向不一致(类似ViewPager和Fragment组合,每个页面又有ListView)
  2. 外部滑动方向和内部滑动方向一致(ScrollView和ListView组合)
  3. 以上两种情况的嵌套

滑动冲突的处理规则:

  • 对于由于外部滑动和内部滑动方向不一致导致的滑动冲突,可以根据滑动的方向判断谁来拦截事件。
  • 对于由于外部滑动方向和内部滑动方向一致导致的滑动冲突,可以根据业务需求,规定何时让外部View拦截事件,何时由内部View拦截事件。
  • 对于上面两种情况的嵌套,相对复杂,可同样根据需求在业务上找到突破点。

滑动冲突的解决方法

  1. 外部拦截法

指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。具体方法:需要重写父容器的onInterceptTouchEvent方法,在内部做出相应的拦截。

@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;     //不拦截
        
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastXIntercept;
            int deltaY = y - mLastYIntercept;
            if (Math.abs(deltaX) > Math.abs(deltaY)) {  //左右滑动距离大于上下,则拦截
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false; //不拦截
            break;
        }
        default:
            break;
        }

        mLastXIntercept = x;
        mLastYIntercept = y;

        return intercepted;
    }

上述代码,在onInterceptTouchEvent方法中,首先是ACTION_DOWN这个事件中,父容器必须返回false,即不拦截ACTION_DOWN事件,因为一旦父容器拦截了ACTION_DOWN,那么后续的事件都会直接交由父容器处理,这个时候事件就没法再传递给子元素了;其次ACTION_MOVE事件根据需求来决定;最好ACTION_UP事件,这里必须返回false,如果返回true会导致子元素无法接收到ACTION_UP事件,这个时候子元素中的onClick事件就无法触发。但父容器比较特殊,一旦拦截了任何一个事件,后续事件都会交由它来处理。

  1. 内部拦截法

指父容器不拦截任何事件,而将所有的事件都传递给子容器,如果子容器需要此事件就直接消耗,否则就交由父容器进行处理。具体方法:需要配合requestDisallowInterceptTouchEvent方法(影响标记位FLAG_DISALLOW_INTERCEPT)。
,需要重写子元素的dispatchTouchEvent方法以及父元素的InterceptTouchEvent

子元素重写dispatchTouchEvent方法,配合requestDisallowInterceptTouchEvent方法决定是否允许父元素拦截


image.png

父元素重新InterceptTouchEvent方法


image.png

容器需要拦截处理ACTION_DOWN之外的所有事件,这样当调用requestDisallowInterceptTouchEvent(false)时,父容器才能继续拦截所需的事件。因为ACTION_DOWN事件是不受FLAG_DISALLOW_INTERCEPT标记位控制的,所以一旦父容器拦截了ACTION_DOWN,那么所有事件都无法传递到子元素中了。

以上demo在move时会被父元素处理,其他给子元素处理。

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

推荐阅读更多精彩内容