结合实例,一篇文章彻底理清OnTouchListener、onTouchEvent、onClick、clickable与事件传递机制的关系

本文将结合具体实例:通过微信聊天页面的交互方式,分析实现方法,进而搞清 OnTouchListener、onTouchEvent、onClick、clickable的关系。

说明 1:本文默认读者已经基本了解事件分发机制,主要是 dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent 流程。

说明 2:文中代码以 Android SDK 23 为参考,如果想要亲自调试一下,可以将 compileSdkVersion 设置为 23,并且安装 Nexus 模拟器,模拟器系统版本要与 compileSdkVersion 一致。最好不要使用真机调试,即使系统版本对应,一般也会因为手机厂商对原生系统的改动,导致调试时代码行数不对应。

微信聊天页面示例

交互分析

image
  • 分析:上图为一张普通的微信聊天页面图,使用经验告诉我们,在当前页面状态下,如果点击聊天文本或者聊天语音,键盘是不会收起的,而点击聊天图片,键盘是会收起的,而且点击任何聊天信息之外的区域(空白区域),键盘都是会收起的。
  • 更仔细的观察发现,点击空白区域键盘收起这一操作,并不是一个 click 事件,而是一旦触摸空白区域,键盘就会立马收起。
  • 综上所述,当键盘已经弹出后,点击或触摸聊天界面不同区域,会让键盘有不同的动作(保持不变或者收起)

实现方法

  • 上述交互需求设计分析如下(假设列表是一个 ListView 控件)
    • ListView 应该是被设置了 Touch 事件,而不是 click 事件,因为一旦触摸(TouchDown)就会执行,如果是设置的 click 事件,需要点击(手指抬起)才能够执行。可能的实现方法有两种:
      1. 继承 ListView 并重写 dispatchTouchEvent(TODO 可以重写其他方法吗?)方法,执行隐藏键盘操作,然后调用 super.dispatchTouchEbent 方法正常分发。
        • 这种方案基本可以排除,因为只能统一对键盘进行收起操作,点击聊天文本不需要收起键盘的场景就很难处理,换句话说,此时子 View 是无法控制父 View 设置的这一行为。
      2. 给 ListView 设置 OnTouchListener,重写 onTouch 方法,在 TouchDown 时隐藏键盘,这样可以实现触摸 ListView 时收起键盘。另外,子 View 可以通过设置自己的点击事件,而达到 ListView 的 OnTouchListener 不被执行的目的,即子 View 设置自己的点击事件,自己单独处理键盘是否需要隐藏,看上去好像子 View 拦截了 ListView 的 onTouch 方法,这种方案是否真的可行呢?子 View 是如何实现让父 View 的 onTouch 事件得不到执行的呢?带着这样的问题,我们来分析一下 View 的事件传递机制。

View 的事件传递

View.dispatchTouchEvent

  • 先从 View 的事件分发看起,这里的 View 不包含 ViewGroup,把 View 的 dispatchTouchEvent 关键的代码摘出来,如下所示:
public boolean dispatchTouchEvent(MotionEvent event) {

        // 省略部分代码
        
        if (onFilterTouchEventForSecurity(event)) {
            if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
                result = true;
            }
            //noinspection SimplifiableIfStatement
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) { // 注释 1
                result = true;
            }

            if (!result && onTouchEvent(event)) { // 注释 2
                result = true;
            }
        }

        // 省略部分代码

        return result;
    }
  • 代码很明显,先判断是否给当前 View 设置了 OnTouchListener 事件,即 mOnTouchListener 是否为空,当不为空时,调用 mOnTouchListener 的 onTouch 方法(注释 1 处)
  • 该判断过程发生在 if 语句中,可见 onTouch 返回值影响到 result 的结果,而 result 又在注释 2 的判断中用到,假设 onTouch 返回了 true,则注释 2 处的 onTouchEvent 方法是得不到执行的,而该方法就是我们熟悉的事件传递机制中的消费事件的方法。
  • 综上,我们可以得出结论:OnTouchListener 的优先级是高于 onTouchEvent 的,并且 OnTouchListener 的返回值能够决定是否还会执行 onTouchEvent 方法;


    View 的 事件分发机制

ViewGroup.dispatchTouchEvent

  • 再来分析一下 ViewGroup 的 dispatchTouchEvent 方法,精简后的代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
        
        // 省略代码 ...

        boolean handled = false;
        if (onFilterTouchEventForSecurity(ev)) {
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;

            // 当为 ACTION_DOWN 时,说明是一个事件序列的开始,会调用 resetTouchState 方法重置状态
            // Handle an initial down.
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                // Throw away all previous state when starting a new touch gesture.
                // The framework may have dropped the up or cancel event for the previous gesture
                // due to an app switch, ANR, or some other state change.
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }

            // mFirstTouchTarget 是用来记录接收该事件的子 View 的,当为 null 时,说明还没有子 View 接收该事件序列,不为空时,说明已经有子 View 接收了该事件,事件序列的其他事件就可以直接传给该 View。
            // 这一段代码主要是检查要不要对事件进行拦截:onInterceptTouchEvent
            // Check for interception.
            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;
            }

            // 省略代码 ...
            
            TouchTarget newTouchTarget = null;
            boolean alreadyDispatchedToNewTouchTarget = false;
            if (!canceled && !intercepted) {

               // 省略代码 ...

                if (actionMasked == MotionEvent.ACTION_DOWN
                        || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                     
                     // 省略代码 ...

                    final int childrenCount = mChildrenCount;
                    if (newTouchTarget == null && childrenCount != 0) {
                        final float x = ev.getX(actionIndex);
                        final float y = ev.getY(actionIndex);
                        // 下面会循环遍历子 View,找到可以接收事件的那个
                        // Find a child that can receive the event.
                        // Scan children from front to back.
                        final ArrayList<View> preorderedList = buildTouchDispatchChildList();
                        final boolean customOrder = preorderedList == null
                                && isChildrenDrawingOrderEnabled();
                        final View[] children = mChildren;
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            final int childIndex = getAndVerifyPreorderedIndex(
                                    childrenCount, i, customOrder);
                            final View child = getAndVerifyPreorderedView(
                                    preorderedList, children, childIndex);

                            // 省略代码 ...
                            newTouchTarget = getTouchTarget(child);
                            // 省略代码 ...
                            // dispatchTransformedTouchEvent 可以看成将事件传递给参数 child,即调用了 child 的 dispatchTouchEvent 方法
                            // 如果child 是 ViewGroup,这个过程相当于递归调用;如果 child 是 View,则调用我们上一小节分析的方法。
                            // 最终的返回值也即 child 的 dispatchTouchEvent 的返回值,如果是 true,说明该 child (或其子 View)消费了事件
                            if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                                // Child wants to receive touch within its bounds.
                                // 省略代码 ...
                                // 调用 addTouchTarget 方法,将找到的接收事件的子 View 保存起来,也会给 mFirstTouchTarget 赋值
                                newTouchTarget = addTouchTarget(child, idBitsToAssign);
                                alreadyDispatchedToNewTouchTarget = true;
                                break;
                            }
                            // 省略代码 ...
                        }
                    }
                     // 省略代码 ...
                }
            }

            // Dispatch to touch targets.
            if (mFirstTouchTarget == null) { // 注释 3
                // mFirstTouchTarget 为空,说明没找到接收事件的子 View
                // 此时调用 dispatchTransformedTouchEvent 方法,传入 View 参数为 null 时,会调用 super.dispatchTouchEvent
                // 即调用到上面分析的 View 的 dispatchTouchEvent,以确定是否由当前 View 消费事件
                // 这就是事件分发中常见的结论:“事件由父 View 向下传递,如果没有子 View 消费事件,事件又会依次向上传递”
                // 实际上并不是向上传递(也就是不是直接调用的 parent.dispatchTouchEvent)而是ViewGroup 先调子 View 的 dispatchTouchEvent 方法,如果没有接收的,再调用自己的 dispatchTouchEvent 方法,以达到“向上传递”的效果
                // No touch targets so treat this as an ordinary view.
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {
                // Dispatch to touch targets, excluding the new touch target if we already
                // dispatched to it.  Cancel touch targets if necessary.
                // 注释 4 后面讲解
            }

            // 当为 ACTION_UP 事件时,说明事件序列结束,也会调用 resetTouchState 方法重置状态
            // Update list of touch targets for pointer up or cancel, if needed.
            if (canceled
                    || actionMasked == MotionEvent.ACTION_UP
                    || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                resetTouchState();
            } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
                final int actionIndex = ev.getActionIndex();
                final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
                removePointersFromTouchTargets(idBitsToRemove);
            }
        }

        // 省略代码 ...
        return handled;
    }
  • 上面代码虽然有点长,但是关键位置都加了注释,注释很重要,务必结合注释看一遍。总结过程可以得到如下流程图:


    ViewGroup_dispatchTouchEvent

将理论应用到需求中

  • 分析完 View 的事件传递机制,我们回头解决需求中的遗留问题:子 View 是如何实现让父 View 的 onTouch 事件得不到执行呢?
  • 根据分析得知,ViewGroup 会先遍历子 View,子 View 不消费事件的话,ViewGroup 才有机会消费事件。而 ListView 就是一个 ViewGroup,每个 ItemView 就是 ListView 的子 View,如此一来:
    • 如果让 ItemView 消费事件,即 onTouchEvent 事件返回 true,则该 View 的 dispatchTouchEvent 也会返回 true,ListView 的 dispatchTouchEvent 在遍历完子 View 后发现有子 View 接收了事件,就没有机会执行**注释 3 **处的代码,更没机会调用 super.dispatchTouchEvent,即没有调用 View 的 dispatchTouchEvent 方法,根据在 View.dispatchTouchEvent 小节中的介绍,在 View.dispatchTouchEvent 中才会调用 OnTouchListener 的 onTouch 方法。
    • 相反,如果让 ItemView 不消费事件,在点击区域内 ListView 就没有找到接收事件的子 View,从而调用 View.dispatchTouchEvent,使得 OnTouchListener 的 onTouch 方法得以执行。
    • 总之,ItemView 是否消费事件,决定了 ListView 的 OnTouchListener 能否得到执行。
  • 至此,我们可以针对需求给出设计方案:为 ListView 设置 OnTouchListener 监听,在 onTouch 方法中隐藏键盘(记得返回false,以便 ListView 的 onTouchEvent 方法和 click 方法还能够得到执行,虽然可能和本例无关)。然后让聊天文本和聊天语音消息对应的 ItemView 能够消费事件,这样 ListView 设置 OnTouchListener 就不能起作用,键盘也就不会消息,满足需求;同理可以让图片消息对应的 ItemView 不消费 Touch 事件,或者消费事件,但在消费事件的方法中自己处理键盘隐藏,并可以再做其他操作,比如微信的放大图片。
  • 最后一个问题,如果让 View(包括 ViewGroup)消费掉一个事件的,又事件传递基础知识我们可知,最直接的方式是,让其 onTouchEvent 方法返回 true,并不是要每一类 ItemView 都去重写 onTouchEvent 方法,我们最后再来分析一下 onTouchEvent 方法,看看可以通过哪些设置,让 onTouchEvent 返回 true。

View.onTouchEvent

  • 只保留与我们需求有关的代码,精简后的方法如下:
public boolean onTouchEvent(MotionEvent event) {
    // 省略代码 ...
    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
                || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
    // 省略代码 ...
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    // // 省略代码 ...
                    break;

                case MotionEvent.ACTION_DOWN:
                    // 省略代码 ...
                    break;

                case MotionEvent.ACTION_CANCEL:
                    // 省略代码 ...
                    break;

                case MotionEvent.ACTION_MOVE:
                    // 省略代码 ...
                    break;
            }

            return true;
        }

    return false;
}
  • 省略后的代码所剩无几,但是对于我们分析问题已经足够了,我们可以看到,当满足 clickable 条件时,无论 Touch 事件的 action 是什么,onTouchEvent 方法都会返回true;相反,当不满足 clickable 也不满足 (viewFlags & TOOLTIP) == TOOLTIP (该条件先不用关心)时,onTouchEvent 就会返回 false。
  • 方法开始处给出了 clickable 的来源,即当前 View 是否是 CLICKABLE 或 LONG_CLICKABLE 或 CONTEXT_CLICKABLE,这三个属性可以通过 setClickable、setLongClickable、setContextClickable 来设置,也就是三个属性有任意一个为 true,就会使 if 条件成立,从而使 onTouchEvent 返回 True。

最终方案

  • 经过上面的分析,我们再次回到微信聊天页面的需求,可以得出如下切实可行的设计方案:
    • ListView 设置 onTouchListener,在 onTouch 方法中实现隐藏软键盘的逻辑;
    • 将聊天文本、聊天语音对应的 ItemView 的 clickable 属性设置为 true,使 ListView 的 onTouchListener 得不到执行。
      • 如果点击之后有其他逻辑,比如微信的文本消息长按会弹出菜单,也可以直接给 ItemView 设置 setOnLongClickListener,在该方法中,View 会先调用 setLongClickable(true),setOnClickListener 则会调用 setClickable(true)
    • 将聊天图片对应的 ItemView 的 clickable 设置为 false,或者如果像微信那样,点击聊天图片,不仅隐藏键盘还要放大图片,就直接设置 setOnClickListener,单独处理键盘隐藏并处理图片放大效果。

知识拓展

  • 我们结合一个实例,通过事件传递机制,给出了实现方案,同时也收货了不少知识:

    • 点击或者长按(Click、LongClick)事件会在 onTouchEvent 中被调用,那么对于单个 View 来讲,可以得出如下事件优先级顺序:
      • onTouch(如果设置了onTouchListener)> onTouchEvent > OnClick
      • 如果onTouch返回true,onTouchEvent 得不到调用;onTouchEvent中检查当前是否设置OnClickListener,决定是否执行onClick,因而onTouchEvent优先级高于click。
      • 如果把 clickable 属性和 dispatchTouchEvent 方法加进去的话,优先级应该为:
        • dispatchTouchEvent > onTouch > onTouchEvent > clickable > OnClick
    • View 的 onTouchEvent 默认都会消耗事件,除非是不可点击的(CLICKABLE LONG_CLICKABLE 和 CONTEXT_CLICKABLE 同时为 false)。View 的 LONG_CLICKABLE 默认为 false,而 CLICKABLE 要看具体控件,如 Button 的 CLICKABLE 为 true,TextView 的 CLICKABLE 为 false。
    • View 的 enable 属性不影响 onTouchEvent 的默认返回值。
  • CANCEL 事件的由来

    • View 的触摸事件中(onTouchEvent)会包含对CANCEL 事件的处理,那 Cancel 是什么?从何而来呢?
    • 答案依然藏在 ViewGroup 的 ouDispatchTouchEvent 中,在上面的注释 4处,单独再拿出来分析下
          // Dispatch to touch targets.
          if (mFirstTouchTarget == null) {
              // No touch targets so treat this as an ordinary view.
              handled = dispatchTransformedTouchEvent(ev, canceled, null,
                      TouchTarget.ALL_POINTER_IDS);
          } else {
              // Dispatch to touch targets, excluding the new touch target if we already
              // dispatched to it.  Cancel touch targets if necessary.
              TouchTarget predecessor = null;
              TouchTarget target = mFirstTouchTarget;
              while (target != null) {
                  final TouchTarget next = target.next;
                  if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                      handled = true;
                  } else {
                      final boolean cancelChild = resetCancelNextUpFlag(target.child)
                              || intercepted; // 注释 4
                      if (dispatchTransformedTouchEvent(ev, cancelChild,
                              target.child, target.pointerIdBits)) {
                          handled = true;
                      }
  • 外层 else 的分支的意思是,此时 mFirstTouchTarget 不为空,即已经有子 View 接收了事件了,但是在注释 4 处看到,intercepted 又为 true,表示父 View 此时要拦截事件,这种情况下,事件的主导权会重新回到父 ViewGroup,那么接下来就调用了 dispatchTransformedTouchEvent 方法并且传入的 cancelChild 为 true,此方法中变回包装一个 ACTION_CANCEL 的事件传给 child。
  • 所以 ViewGroup 的分发很重要,每次分发时,首先处理要不要拦截,其次才去找是不是传给合适的子 View 处理,也就是说在任何分发过程中,父 ViewGroup 都可以进行拦截;同时也警告我们,在重新 onTouchEvent 事件时,不要忽略对 CANCEL 事件的处理。

参考

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

推荐阅读更多精彩内容