美文网首页
结合实例,一篇文章彻底理清OnTouchListener、onT

结合实例,一篇文章彻底理清OnTouchListener、onT

作者: Ronnie_火老师 | 来源:发表于2020-02-03 13:18 被阅读0次

    本文将结合具体实例:通过微信聊天页面的交互方式,分析实现方法,进而搞清 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 事件的处理。

    参考

    相关文章

      网友评论

          本文标题:结合实例,一篇文章彻底理清OnTouchListener、onT

          本文链接:https://www.haomeiwen.com/subject/ptjhxhtx.html