Android事件传递、多点触控及滑动冲突的处理

基本概念

  1. 所有Touch事件都会被封装MotionEvent, 包括Touch的类型、位置(相对屏幕的绝对位置,相对View的相对位置)、时间、历史记录以及第几个手指(多点触控)等;
  2. 事件有多种类型,常用的事件类型有:ACTION_DOWN,ACTION_UP,ACTION_MOVE,ACTION_CANCEL 等;
  3. 对事件的处理包括三类:
    事件传递,dispatchTouchEvent();
    拦截,onInterceptTouchEvent();
    消费,onTouchEvent()、OnTouchListener;

传递过程

网上有很多资料对事件的分发过程做了详尽的代码追踪,比如 https://www.jianshu.com/p/38015afcdb58

有兴趣的同学可以参考并去详细走一下,这里我做一个文字性描述:

传递细节描述

  1. 事件从 Activity.dispatchTouchEvent() 开始传递, 依次通过getWindow().superDispatchTouchEvent(event)、mDecor.superDispatchTouchEvent(event) 传递,即从Activity-> PhoneWindow ->DecorView, DecorView 是整个 ViewTree 的顶层 ViewGroup ;
  2. 在整个 ViewGroup 中,事件从顶层开始,依次往子View传递;
  3. 父 ViewGroup 可以通过 onInterceptTouchEvent() 对事件做拦截,阻止其往下传递;
  4. 如果未被拦截,则子 View 可以通过 onTouchEvent() 消费(处理)事件;
  5. 如果事件从上往下传递过程中一直没有被拦截,且最底层子 View 没有消费事件,事件会反向往上传递,这时父 ViewGroup 可以在 onTouchEvent() 中消费该事件,如果还是没有被消费的话,最后会到 Activity 的 onTouchEvent() 函数;
  6. 底层View是具有事件的优先消费权的;
  7. 如果View 没有对 ACTION_DOWN 进行消费,此次点击的后续事件不会传递过来;
  8. 如果 View 消费了 ACTION_DOWN ,此次点击的后续事件会直接给这个 View,这里的后续事件指的是 ACTION_MOVE 和 ACTION_UP 事件;此时,其父 ViewGroup 的 onIntercept 函数仍会被调用,仍能进行拦截,但它自己的 onIntercept 不会被调用了;
  9. 子 View 可以在 onTouchEvent 中调用 getParent().requestDisallowInterceptTouchEvent(true),这样父 ViewGroup 的 onIntercept 在后续的事件中就不会被调用了;
  10. 如果第一个事件即 ACTION_DOWN 就被父 ViewGroup 拦截了,子 View 将不会获取到消费事件的机会;
  11. OnTouchListener 优先于 onTouchEvent() 对事件进行消费;
  12. 消费指的是相应的函数返回 true ;
  13. ViewGroup 才有 onIntercept 方法,View 是没有的,即View不可以拦截事件;
  14. 所有的事件处理过程都是以 ACTION_DOWN 开始,ACTION_UP 或者 ACTION_CANCEL 结束,ACTION_UP 是事件正常处理逻辑的结束标志,ACTION_CANCEL 是由父 ViewGroup 主动发出,当父 ViewGroup 拦截了除 ACTION_DOWN 之外的事件,会给正在消费 ACTION_DOWN 并等待后续事件的子 View 发送一个 ACTION_CANCEL 事件,通知子 View 结束自己的事件等待;

TouchTarget

关于第7、8两点,ViewGroup是如何在 dispatchTouchEvent 过程中快速命中并分发到对应子 View 的呢?这里是通过 TouchTarget 这个结构来实现的。

private static final class TouchTarget {
        private static final int MAX_RECYCLED = 32;
        
        // 用于控制同步的锁
        private static final Object sRecycleLock = new Object[0];
        
        // 注意这是static类型的,内部可复用实例链表表头
        private static TouchTarget sRecycleBin;
        
        // 内部可复用的实例链表的长度
        private static int sRecycledCount;

        public static final int ALL_POINTER_IDS = -1; // all ones

        // 当前被触摸的 View
        public View child;
        
        // 对目标捕获的所有指针的指针id的组合位掩码
        public int pointerIdBits;

        // 链表中指向的下一个目标
        public TouchTarget next;

        private TouchTarget() {
        }

        ...
}

在ViewGroup中维护了一个变量:mFirstTouchTarget,这是在 ViewGroup 中维护的链表, 用于记录当前响应事件序列的子 View (一个事件序列对应一个响应它的子View),mFirstTouchTarget 指向链表首部。

先看一下 mFirstTouchTarget 的赋值:

// 这是发生在ViewGroup中的dispatchTouchEvent方法中
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
    ...
    mLastTouchDownX = ev.getX();
    mLastTouchDownY = ev.getY();
    newTouchTarget = addTouchTarget(child, idBitsToAssign);
}

// 当响应事件的目标child View添加到链表中,同时让 mFirstTouchTarget 指向链表的表头
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}

再看 mFirstTouchTarget 在 dispatchTouchEvent 方法中的使用:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        
        if (onFilterTouchEventForSecurity(ev)) {
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;

            1、如果事件是 ACTION_DOWN 事件,重置 touchTargets 状态,在 cancelAndClearTouchTargets 方法中会发出 ACTION_CANCEL 事件
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }

            2、对于一个事件序列,当其中某一个事件成功拦截时,那么对于剩下的一系列事件也会被拦截,并且不会再次执行onInterceptTouchEvent方法。如果 ACTION_DOWN 事件被拦截了,即当前ViewGroup的 onInterceptTouchEvent(ev) return true;此时 mFirstTouchTarget 必然为null,后续的事件都会当前 ViewGroup 拦截不再传递
            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 {
                intercepted = true;
            }

            3、如果事件既没有cancel,也没有被 intercept,遍历子View进行事件分发
            if (!canceled && !intercepted) {
                ...
            }

            4、事件分发过程中,如果dispatchTouchEvent返回了false,或者说当前的ViewGroup没有子元素的话,会走到这个逻辑。mFirstTouchTarget == null说明子View并没有消费事件,所以没有对mFirstTouchTarget进行赋值。这里child == null,代码会进一步执行super.dispatchTouchEvent(event),即 View 中的 dispatchTouchEvent 方法
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {

            5、mFirstTouchTarget != null, 说明事件被子View消费,此时会依次将事件分发到 mFirstTouchTarget 保存的链表 View中
                TouchTarget predecessor = null;
                TouchTarget target = mFirstTouchTarget;
                while (target != null) {
                    final TouchTarget next = target.next;
                    ...
                    target = next;
                }
            }
        }

        if (!handled && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
        }
        return handled;
    }

这个地方重点关注一下1、2、3、4、5几个注释点。现在我们回到7 8两点。

如果View 没有对 ACTION_DOWN 进行消费,此次点击的后续事件不会传递过来。这个很显然,如果没有对 ACTION_DOWN 进行消费,就不会被保存到 TouchTarget 链表中,后续事件的分发是直接往这个链表中进行分发的。

如果 View 消费了 ACTION_DOWN ,此次点击的后续事件会直接给这个 View,这里的后续事件指的是 ACTION_MOVE 和 ACTION_UP 事件;此时,其父 ViewGroup 的 onIntercept 函数仍会被调用,仍能进行拦截,但它自己的 onIntercept 不会被调用了。这个可以从第2点注释中找到答案,如果事件被消费了,mFirstTouchTarget != null, 后续事件可以从mFirstTouchTarget 链表中直接分发,同时后续事件过来的时候会跳过intercepted 的判断,所以自己的 onIntercept 就不会调用了。

RecyclerView 的事件传递

这里以点击 RecyclerView 中的某个Item中的 Button 为例:

点下Button

  1. 产生了一个down事件,activity-->phoneWindow-->ViewGroup-->ListView-->botton,中间如果有重写了拦截方法,则事件被该view拦截可能消耗;
  2. 没拦截,事件到达了button,这个过程中建立了一条事件传递的view链表;
  3. 到button的dispatch方法-->onTouch-->view是否可用-->Touch代理;

移动点击按钮的时候

  1. 产生move事件,RecyclerView 中会对move事件做拦截;
  2. 此时 RecyclerView 会将该滑动事件消费掉;
  3. 后续的滑动事件都会被 RecyclerView 消费掉;
  4. Button之前已经处理了 down 事件,现在还在等着后续事件,这个时候 RecyclerView 就会发出 cancel 事件通知Button不要再等了

手指抬起
前面建立了一个view链表,RecyclerView 的父view在获取事件的时候,会直接取链表中的RecyclerView 让其进行事件消耗

有兴趣的同学可以带着这个步骤去追踪 RecyclerView 的源代码。

多点触控

多点触控涉及到了多个手指点击事件的处理,这里要增加两个额外的事件

  1. ACTION_POINTER_DOWN:额外⼿手指按下(按下之前已经有别的⼿手指触摸到 View)
  2. ACTION_POINTER_UP:有⼿手指抬起,但不不是最后⼀一个(抬起之后,仍然还有别的⼿手指在触摸着 View)

事件类型: ACTION_POINTER_UP;
active pointer index: 0;pointer: x: 200, y: 300, index: 0, id: 1;
pointer: x: 300, y: 500, index: 1, id: 2

多点触控触摸事件的结构

  1. 触摸事件是按序列列来分组的,每⼀一组事件必然以 ACTION_DOWN 开头,以 ACTION_UP 或 ACTION_CANCEL 结束;
  2. ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 和 ACTION_MOVE ⼀一样,只是事件序列列中 的组成部分,并不不会单独分出新的事件序列列;
  3. 同⼀一时刻,⼀一个 View 要么没有事件序列列,要么只有⼀一个事件序列列;
  4. 多点触控要解决的问题之一是:手指触摸的顺序,手指的区分,这两个问题通过 index 和 id 来区分;
  5. 多点触控要解决的问题二:多点触控时滑动了一个手指,这时候要知道动的是哪个

多点触控的三种类型

  • 接⼒力力型 同⼀一时刻只有⼀一个 pointer 起作⽤用,即最新的 pointer。 典型:ListView、 RecyclerView。 实现⽅方式:在 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 时记录下最 新的 pointer,在之后的 ACTION_MOVE 事件中使⽤用这个 pointer 来判断位置。
  • 配合型 所有触摸到 View 的 pointer 共同起作⽤用。
    典型:ScaleGestureDetector,以及 GestureDetector 的 onScroll() ⽅方法判断。 实现⽅方式:在 每个 DOWN、POINTER_DOWN、POINTER_UP、UP 事件中使⽤用所有 pointer 的坐标来共同更更新焦点坐标,并在 MOVE 事件中使⽤用所有 pointer 的坐标来判断位置。
  • 各⾃自为战型 各个 pointer 做不不同的事,互不不影响。 典型:⽀支持多画笔的画板应⽤用。 实现⽅方式: 在每个 DOWN、POINTER_DOWN 事件中记录下每个 pointer 的 id,在 MOVE 事件中使⽤用 id 对 它们进⾏行行跟踪。

滑动冲突处理

什么是滑动冲突?就是父 View 和子 View 都需要处理滑动,例如父 View 需要左右滑动,子 View 需要上下滑动(ViewPager 嵌套 RecyclerView),一个点击事件,到底交给谁处理?

首先我们需要定义好处理规则,然后我们在父 View 的 onIntercept、子 View 的 onTouchEvent 以及父 View 的 onTouchEvent 函数中实现我们定义的规则即可。例如父 View 的 onIntercept 中,如果发现是左右滑动,那就拦截,否则不拦截。

NestedScrollView 嵌套 RecyclerView 也是一样的道理,NestedScrollView 发现是上下滑动,就直接拦截并处理,RecyclerView 就没有处理的机会了。

参考文章

Piasy:事件传递及滑动冲突的处理

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

推荐阅读更多精彩内容