Android 中手指从按钮 A 平移到 B,会发生什么?为什么?

前言

Touch 相关问题是 Android 面试中常问的点,不一定要求大家都从 InputFlinger 底层开始回答,但起码需要了解 Touch 抵达 App 之后的完整处理。而即便是这段偏上层的链路,也不要局限在老生常谈的过程复述,需要深刻理解、灵活运用其中的细节和原则。

本文结合一个简单的 Touch 场景的问答,带大家加深一下 Touch 分发的理解。

  1. Button A 和 B 相邻,手指不抬起、从 A 平移到 B,A 会发生什么?为什么?
  2. 此刻,B 又会发生什么?为什么?
  3. 之后,手指再从 B 平移回 A 后,又会发生什么?为什么?
  4. 最后,在 A 上抬起手指,A 会触发点击吗?为什么?

验证

我们自定义两个 Button 分别覆写其 onTouchEvent(),在一个 ConstraintLayout 中上下紧密地放置它们,并为了区分设置为不同的背景色。

按照提问的问题步骤开始尝试一下。

可以看到手指平移到 B 的那一刻,A 的 press 效果没有了,而 B 没有任何反应。即便移动回 A,A 也无法恢复 press 效果,抬起之后也没有触发 click。

解答

解答原理之前,我们先看下 log,再逐一解释。

css 复制代码
 // 手指在 A 上按下
 2023-09-12 18:11:25.209 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_DOWN, actionButton=0, id[0]=0, x[0]=109.9668, y[0]=74.92432, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1823125, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=530500549 }
 ​
 // 手指开始向下移动
 2023-09-12 18:11:25.586 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=109.9668, y[0]=78.92334, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1823538, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=348888341 }
 2023-09-12 18:11:25.633 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=109.9668, y[0]=82.92236, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1823591, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=354173977 }
 ...
 2023-09-12 18:11:26.200 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=155.50244, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1824161, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=195296965 }
 2023-09-12 18:11:26.216 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=163.84363, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1824177, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=273686682 }
 ​
 // Button 高度为 168px,此刻已开始出界到 B
 2023-09-12 18:11:26.233 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=174.2472, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1824194, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=758026894 }
 2023-09-12 18:11:26.250 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=121.96387, y[0]=178.18982, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=1, eventTime=1824211, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=498491454 }
 ...
 2023-09-12 18:11:26.801 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=129.96191, y[0]=266.87744, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1824754, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=936130601 }
 ​
 // 手指开始往上移动
 2023-09-12 18:11:27.484 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=129.96191, y[0]=262.87842, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1825443, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=17662257 }
 ...
 2023-09-12 18:11:27.585 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=137.95996, y[0]=244.88281, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825541, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=507118427 }
 ...
 2023-09-12 18:11:27.966 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=145.16235, y[0]=175.69556, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825927, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=876127266 }
 ​
 // Button 高度为 168px,此刻已移动回到 A
 2023-09-12 18:11:27.985 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=145.95801, y[0]=166.91626, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825944, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=352798882 }
 2023-09-12 18:11:28.000 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=149.15863, y[0]=162.90283, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=2, eventTime=1825961, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=99105321 }
 ...
 2023-09-12 18:11:28.369 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=161.9541, y[0]=86.92139, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1826312, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=764248821 }
 2023-09-12 18:11:28.722 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=161.9541, y[0]=90.92041, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1826673, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=197617005 }
 ​
 // 手指从 A 上抬起
 2023-09-12 18:11:28.947 8944-8944/com.ellison.demo D/Touch: ButtonA#onTouchEvent() event:MotionEvent { action=ACTION_UP, actionButton=0, id[0]=0, x[0]=161.9541, y[0]=90.92041, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=1826912, downTime=1823125, deviceId=8, source=0x5002, displayId=0, eventId=250168391 }

1. 平移到 B,A 会发生什么?

A 的 pressed 效果会被重置。

以往大家会直观地以为这是 ViewGroup 发送 ACTION_CANCEL 给 ButtonA 造成了的。

但观察 log 你会发现,即便出界了,ACTION_MOVE 始终发给了 ButtonA。同时,随着手指的不断向下移动,ACTION_MOVE 的 y 相对坐标不断增大,当该 y 数值超过了 mBottom - mTop 的高度差的时候,Button 的父亲 View 的 onTouchEvent() 会基于其离开了 View 边界调用 setPressed(false) 去刷新 View 的 Press 状态,继而促使 ButtonA 的按下状态消失了。

arduino 复制代码
 public class View ... {
     ...
     public boolean onTouchEvent(MotionEvent event) {
         ...
         if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
             switch (action) {
                 ...
                 case MotionEvent.ACTION_MOVE:
                     ...
                     // Be lenient about moving outside of buttons
                     if (!pointInView(x, y, touchSlop)) {
                         ...
                         if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                             setPressed(false);
                         }
                         mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                     }
                     ...
                     break;
             }
 ​
             return true;
         }
 ​
         return false;
     }
 ​
     /*package*/ final boolean pointInView(float localX, float localY) {
         return pointInView(localX, localY, 0);
     }
 ​
     public boolean pointInView(float localX, float localY, float slop) {
         return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
                 localY < ((mBottom - mTop) + slop);
     }
     ...
 }

2. B 会发生什么?为什么?

B 没有任何反应。

其实,解答问题 1 时已经侧面解答了 B 没有反应的直接原因:ButtonB 没有收到任何 TouchEvent。

那为什么即便手指移动到了 B 区域,系统仍不发送事件过去呢?

Button 的父布局 ViewGroup 在分发 ACTION_DOWN 的时候,通过 addTouchTarget() 将处理 DOWN 事件的 child 赋值到 mFirstTouchTarget 。后续来了 ACTION_MOVE 的时候,发现 mFirstTouchTarget 已存在,就将后续事件通过 dispatchTransformedTouchEvent() 继续发给该 TouchTarget

源码中的注释也体现了这点:

Dispatch to touch targets, excluding the new touch target if we already dispatched to it.

ini 复制代码
 public abstract class ViewGroup extends View implements ViewParent, ViewManager {
     ...
     @Override
     public boolean dispatchTouchEvent(MotionEvent ev) {
         ...
         boolean handled = false;
         if (onFilterTouchEventForSecurity(ev)) {
             ...
             if (!canceled && !intercepted) {
                 ...
                 if (actionMasked == MotionEvent.ACTION_DOWN ...) {
                     ...
 ​
                     final int childrenCount = mChildrenCount;
                     if (newTouchTarget == null && childrenCount != 0) {
                         ...
                         final View[] children = mChildren;
                         for (int i = childrenCount - 1; i >= 0; i--) {
                             ...
                             resetCancelNextUpFlag(child);
                             if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                                 // Child wants to receive touch within its bounds.
                                 mLastTouchDownTime = ev.getDownTime();
                                 if (preorderedList != null) {
                                     // childIndex points into presorted list, find original index
                                     for (int j = 0; j < childrenCount; j++) {
                                         if (children[childIndex] == mChildren[j]) {
                                             mLastTouchDownIndex = j;
                                             break;
                                         }
                                     }
                                 } else {
                                     mLastTouchDownIndex = childIndex;
                                 }
                                 mLastTouchDownX = ev.getX();
                                 mLastTouchDownY = ev.getY();
                                 newTouchTarget = addTouchTarget(child, idBitsToAssign);
                                 alreadyDispatchedToNewTouchTarget = true;
                                 break;
                             }
                             ...
                         }
                         ...
                     }
                     ...
                 }
             }
 ​
             if (mFirstTouchTarget == null) {
                 handled = dispatchTransformedTouchEvent(ev, canceled, null,
                         TouchTarget.ALL_POINTER_IDS);
             } else {
                 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;
                         if (dispatchTransformedTouchEvent(ev, cancelChild,
                                 target.child, target.pointerIdBits)) {
                             handled = true;
                         }
                         ...
                     }
                     predecessor = target;
                     target = next;
                 }
             }
             ...
         }
         ...
         return handled;
     }
     ...
 }

3. B 平移回 A 后,又会发生什么?

A 也不再有任何反应。

Button 的父亲 View 只在接受到 ACTION_DOWN 的时候能够调用 setPressed() 展示 pressed 效果。所以即便手指回到了 A 区域也不会触发按下 UI 的变化。

scss 复制代码
 public class View ... {
     ...
     public boolean onTouchEvent(MotionEvent event) {
         ...
         if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
             switch (action) {
                 case MotionEvent.ACTION_UP:
                     mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                     if ((viewFlags & TOOLTIP) == TOOLTIP) {
                         handleTooltipUp();
                     }
                     if (!clickable) {
                         removeTapCallback();
                         removeLongPressCallback();
                         mInContextButtonPress = false;
                         mHasPerformedLongPress = false;
                         mIgnoreNextUpEvent = false;
                         break;
                     }
                     boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                     if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                         // take focus if we don't have it already and we should in
                         // touch mode.
                         boolean focusTaken = false;
                         if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                             focusTaken = requestFocus();
                         }
 ​
                         if (prepressed) {
                             // The button is being released before we actually
                             // showed it as pressed.  Make it show the pressed
                             // state now (before scheduling the click) to ensure
                             // the user sees it.
                             setPressed(true, x, y);
                         }
 ​
                         if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                             // This is a tap, so remove the longpress check
                             removeLongPressCallback();
 ​
                             // Only perform take click actions if we were in the pressed state
                             if (!focusTaken) {
                                 // Use a Runnable and post this rather than calling
                                 // performClick directly. This lets other visual state
                                 // of the view update before click actions start.
                                 if (mPerformClick == null) {
                                     mPerformClick = new PerformClick();
                                 }
                                 if (!post(mPerformClick)) {
                                     performClickInternal();
                                 }
                             }
                         }
 ​
                         if (mUnsetPressedState == null) {
                             mUnsetPressedState = new UnsetPressedState();
                         }
 ​
                         if (prepressed) {
                             postDelayed(mUnsetPressedState,
                                     ViewConfiguration.getPressedStateDuration());
                         } else if (!post(mUnsetPressedState)) {
                             // If the post failed, unpress right now
                             mUnsetPressedState.run();
                         }
 ​
                         removeTapCallback();
                     }
                     mIgnoreNextUpEvent = false;
                     break;         
                 case MotionEvent.ACTION_DOWN:
                     ...
                     if (isInScrollingContainer) {
                         mPrivateFlags |= PFLAG_PREPRESSED;
                         if (mPendingCheckForTap == null) {
                             mPendingCheckForTap = new CheckForTap();
                         }
                         mPendingCheckForTap.x = event.getX();
                         mPendingCheckForTap.y = event.getY();
                         postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                     } else {
                         // Not inside a scrolling container, so show the feedback right away
                         setPressed(true, x, y);
                         checkForLongClick(
                                 ViewConfiguration.getLongPressTimeout(),
                                 x,
                                 y,
                                 TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
                     }
                 ...
             }
 ​
             return true;
         }
 ​
         return false;
     }
 ​
     /*package*/ final boolean pointInView(float localX, float localY) {
         return pointInView(localX, localY, 0);
     }
 ​
     public boolean pointInView(float localX, float localY, float slop) {
         return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
                 localY < ((mBottom - mTop) + slop);
     }
     ...
 }

4. A 会触发点击吗?为什么?

无法触发点击。

原因很简单,从 A 移走的那刻将执行 performClickRunnable 删除了,继而没有机会触发 click 或 longClick。

typescript 复制代码
 public class View ... {
     ...
     public boolean onTouchEvent(MotionEvent event) {
         ...
         if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
             switch (action) {
                 ...
                 case MotionEvent.ACTION_MOVE:
                     ...
                     if (!pointInView(x, y, touchSlop)) {
                         // Outside button
                         // Remove any future long press/tap checks
                         removeTapCallback();
                         removeLongPressCallback();
                         ...
                     }
                     ...
                     break;
             }
 ​
             return true;
         }
 ​
         return false;
     }
     ...
 }

结语

回顾下这 4 个问题的答案和原因。

  1. Button A 和 B 相邻,手指不抬起、从 A 平移到 B,A 会发生什么?为什么?

    A 的按下效果会消失。

    即便手指移出界了,但 MOVE 事件仍然发给了 A,View 发现坐标超过 Button 范围之后重置了 pressed 状态。

  2. 此刻,B 又会发生什么?为什么?

    B 没有任何变化。

    Button A 先收到了 DOWN 事件,导致后续的事件都发给了 A,B 没有收到任何事件,故没有反应。

  3. 之后,手指再从 B 平移回 A 后,又会发生什么?为什么?

    A 也不恢复按下效果。

    View 只在接受到 DOWN 时设置 pressed 状态,即便手指回到了 A,因为没有新的 DOWN 产生,所以无法再次呈现按下效果。

  4. 最后,在 A 上抬起手指,A 会触发点击吗?为什么?

    无法触发 A 的点击。

    手指从 A 出界的那刻将执行 click runnable 一并移除了,后面 UP 的时候没有可以执行的 runnable,故不会执行任何点击、长按点击的回调。

毫无疑问,Android 进行这样的处理是没有问题的。那如果我们想要改变这个逻辑:

  1. 让移动到的目标 Button 呈现 pressed 状态,并在手指抬起的时候响应 click 呢,该怎么实现?

思路也不复杂,简单来说复写 ViewGroupdispatchTouchEvent() 作如下处理即可:

  1. 发现 touchTarget 变更了,向原 target 发送 CANCEL 取消 pressed 效果
  2. 手动 obtain 一个 DOWN event 发送给移动到的 target,进而能使得新 target 能展示 pressed 状态和设置 click runnable
  3. 之后再发送物理上的实际 MOVE 事件给新 target,后面当 UP 的时候因为 DOWN 的时候补充了 runnable,确保 up 时可以执行 click

到这里也就讲完了,这 5 个问题你都答对了吗? 希望本文能帮你加深 Touch 处理的理解。

相关推荐
Redstone Monstrosity29 分钟前
字节二面
前端·面试
Reese_Cool1 小时前
【C语言二级考试】循环结构设计
android·java·c语言·开发语言
UestcXiye2 小时前
面试算法题精讲:求数组两组数差值和的最大值
面试·数据结构与算法·前后缀分解
严格格2 小时前
三范式,面试重点
数据库·面试·职场和发展
平凡シンプル2 小时前
安卓 uniapp跨端开发
android·uni-app
elina80132 小时前
安卓实现导入Excel文件
android·excel
严文文-Chris2 小时前
【设计模式-享元】
android·java·设计模式
趋势大仙2 小时前
SQLiteDatabase insert or replace数据不生效
android·数据库
DS小龙哥2 小时前
QT For Android开发-打开PPT文件
android·qt·powerpoint
试行3 小时前
Android实现自定义下拉列表绑定数据
android·java