一、概述
这篇文章,从UI 的层面分析点击事件是如何分发的,带有完整的源码分析,图解和事件冲突的分析。包含了垂直层次,嵌套分发,定向分发,二次分发,排序算法,判断拦截,事件如何消费。
二、情景
ViewGroup 嵌套 ViewGroup 的场景很常见,如以下的布局情况:
当点击里面 Item 的时候,这个点击事件是如何传递的?为什么是 Item 接受了点击事件而不是外面的 ViewGroup2 接受的?
接下来我们就从源码分析其中的奥秘。
三、onTouchEvent(),dispatchTouchEvent() 和onTouch()
MotionEvent 中有多个动作类型,常见的包括:
ACTION_DOWN:手指按下时触发。ACTION_MOVE:手指在屏幕上移动时触发。ACTION_UP:手指抬起时触发。ACTION_CANCEL:触摸事件被取消时触发。
三个函数的作用:
onTouchEvent():事件触发。dispatchTouchEvent():事件分发。onTouch():事件监听。
在View的dispatchTouchEvent方法中,会先检查是否有OnTouchListener,如果有的话,就会调用它的onTouch方法。如果onTouch返回true,事件被消费,不再传递到onTouchEvent;如果返回false,事件继续传递到onTouchEvent处理。
触发顺序:事件到达 View → 先触发 View 的 dispatchTouchEvent() → 再触发 OnTouchListener 的 onTouch() → 再触发 View 的 onTouchEvent() 。
public interface OnTouchListener {
/**
* 当触摸事件被分发给某个视图时触发此方法。此方法允许监听器在目标视图处理之前
* 优先响应触摸事件。
*
* @param v 接收到触摸事件的视图(View)。
* @param event 包含事件完整信息的 MotionEvent 对象。
* @return 若监听器已消费此事件,返回 true;否则返回 false。
*/
boolean onTouch(View v, MotionEvent event);
}
onTouch() 返回 true 会拦截 onTouchEvent() 的执行,也就是说 onTouchEvent() 当 onTouch() 未消费事件时执行。
为什么是这样的呢?代码是什么样的?这个问题先留着,后面自然就解决了。
众所周知点击事件可以这样添加:
myView.setOnClickListener {
...
}
点击事件是这样触发的(省略了dispatchTouchEvent):
ACTION_DOWN → onTouch() → onTouchEvent() → [ACTION_MOVE → onTouch() → onTouchEvent() → ]ACTION_UP → onTouch() → onTouchEvent() → onClick()
添加触摸事件:
myView.setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
Log.d("MainActivity", "onTouch: ACTION_DOWN")
}
MotionEvent.ACTION_MOVE -> {
Log.d("MainActivity", "onTouch: ACTION_MOVE")
}
MotionEvent.ACTION_UP -> {
Log.d("MainActivity", "onTouch: ACTION_UP")
}
}
return false
}
})
四、视图的两种特殊状态
1.enabled
enabled="false"会禁用所有交互。
2.clickable
enabled="false"禁用点击事件(onClick)的响应。
3.两者优先级
- 当
enabled="false时所有交互失效:
无论clickable的值如何,视图不会响应点击、触摸或输入事件。
外观变化:
- 当
enabled="true"时clickable控制点击事件:
clickable="true" → 响应点击。
clickable="false" → 不响应点击,但其他交互(如长按)可能仍然有效。
五、dispatchTouchEvent()
事件的分发( 也就是 ViewGroup 怎么将事件转交的 ) ,接下来就拆解分析一下 ViewGroup 的dispatchTouchEvent,这部分是重点!!!
我们将ACTION_DOWN开始(起点),ACTION_UP结束(终点)这一过程顺序叫做序列
1.自上而下 垂直层次 嵌套 分发
① 准备层次数据
buildOrderedChildList()采用了插入排序的算法,根据Z轴值对子视图进行排序,从而达到了按照层次自上而下分发的效果。
// 这是一个临时的容器,用于保存预排序的子视图。
// 它仅用于输入处理(如触摸事件分发)和软件绘制过程中
// 以确保子视图按照正确的Z轴顺序进行排序和渲染。
private ArrayList<View> mPreSortedChildren;
// Child views of this ViewGroup
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private View[] mChildren;
ArrayList<View> buildOrderedChildList() {
// 获取子视图的总数
final int childrenCount = mChildrenCount;
// 如果子视图的数量小于等于1,或者没有子视图具有Z轴值,则返回null
if (childrenCount <= 1 || !hasChildWithZ()) return null;
// 省略一系列操作
// ......
// 遍历所有子视图
for (int i = 0; i < childrenCount; i++) {
// 获取下一个子视图的索引,并验证该索引的有效性
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
// 获取对应的子视图
final View nextChild = mChildren[childIndex];
// 获取子视图的Z轴值
final float currentZ = nextChild.getZ();
// 找到应该插入子视图的位置,以确保列表是按照Z轴值升序排列的
int insertIndex = i;
// 从当前位置向前遍历,直到找到一个Z轴值小于或等于当前子视图的Z轴值的位置
while (insertIndex > 0 && mPreSortedChildren.get(insertIndex - 1).getZ() > currentZ) {
insertIndex--;
}
// 在找到的位置插入当前子视图
mPreSortedChildren.add(insertIndex, nextChild);
}
// 返回按照Z轴顺序排列的子视图列表
return mPreSortedChildren;
}
② 层次筛选
上面将数据进行排序了,那总不能触摸点在 View 的外面也能分发到吧,所以isTransformedTouchPointInView()还需要判断一下这个触摸点是不是在 View 的内部。
protected boolean isTransformedTouchPointInView(float x, float y, View child,
PointF outLocalPoint) {
// 定义一个临时数组来存储触摸点的坐标
final float[] point = getTempLocationF();
// 将传入的触摸点坐标赋值给临时数组
point[0] = x;
point[1] = y;
// 将触摸点坐标转换到子视图的本地坐标系
transformPointToViewLocal(point, child);
// 判断转换后的触摸点是否在子视图内
final boolean isInView = child.pointInView(point[0], point[1]);
// 如果触摸点在子视图内,并且传入的outLocalPoint不为null
if (isInView && outLocalPoint != null) {
// 将转换后的本地坐标设置到outLocalPoint中
outLocalPoint.set(point[0], point[1]);
}
// 返回触摸点是否在子视图内的结果
return isInView;
}
③ 开始嵌套分发
- 未形成消费序列(正常分发)
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// 省略一系列操作
// ......
}
先看一下dispatchTransformedTouchEvent()这个函数,其他的先不关注,重点看一下对dispatchTouchEvent() 函数的调用,在函数调用自己,本应该是个递归函数,但是这里的主体变了(注意child和 super),我们对其逐一分析。
到底是child还是super?我们通过代码可以简单的总结为:如果传递进来的child 为 null 就调用child反之则是super。
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
// 省略一系列操作
// ......
if (newPointerIdBits == oldPointerIdBits) {
if (child == null || child.hasIdentityMatrix()) {
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
// 省略一系列操作
// ......
handled = child.dispatchTouchEvent(event);
// 省略一系列操作
// ......
}
return handled;
}
// 省略一系列操作
// ......
} else {
// 省略一系列操作
// ......
}
// 执行任何必要的转换并分发事件。
if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
// 省略一系列操作
// ......
handled = child.dispatchTouchEvent(transformedEvent);
}
// 事件处理完毕,回收资源。
transformedEvent.recycle();
return handled;
}
a. super 为主体(二次分发)
if (child == null) {
handled = super.dispatchTouchEvent(event);
}
也就是调用父类,所以在这里我们需要查看一下 View 类的dispatchTouchEvent(event)方法(上面是查看的是 ViewGroup 类方法)。也就是说到这里,已经走入了消费的逻辑之中(具体的在下个标题中讲),接下来就会根据消费的情况一步一步的 "递归" 回去。
这里重点看onTouch和onTouchEvent是什么时候调用的。
onTouchEvent()``dispatchTouchEvent()和onTouch()三者的关系前面就解释过来了,只是在这里才看到源码,就不细说了。
public boolean dispatchTouchEvent(MotionEvent event) {
// 省略一系列操作
// ......
// 如果有输入事件一致性验证器,则调用它。
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(event, 0);
}
// 省略一系列操作
// ......
// 如果事件通过了安全过滤,则继续处理。
if (onFilterTouchEventForSecurity(event)) {
// 省略一系列操作
// ......
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
// 如果事件还没有被处理,并且视图的onTouchEvent方法处理了事件,则设置结果为true。
if (!result && onTouchEvent(event)) {
result = true;
}
}
// 省略一系列操作
// ......
return result; // 返回事件是否被处理的结果。
}
这里我们可以得出的一个结论就是:ViewGroup 如果没有子 View 那就调用父类 View 的dispatchTouchEvent(),通过dispatchTouchEvent()调用onTouchEvent()和onTouch()来决定石是否消费。
b.child 为主体
if (child == null) {
} else {
handled = child.dispatchTouchEvent(event);
}
调用子 View 的dispatchTouchEvent(),显而易见的将分发过程转交到子 View。如此就形成了嵌套的分发。
我们又能得出一个结论:dispatchTouchEvent()函数返回值的意义就是当前是否有人接受消费事件?true 为消费。
- 已形成消费序列(定向分发)
一个序列可能会包含多个move事件,这样就会造成一个问题:每次move都需要重新寻找哪个 View会最终消费这个事件,但是仔细思考一下,我们会得出这样的一个结论:如果一个 View 他连down事件都不要,他肯定不会要move事件,反之,如果他要了down事件,我们就可以默认他会接受接下来的所有操作,直到 up 事件。所以当他接受了down事件之后,我们就可以将down的分发路径用链表记录下来,move事件就可以直接传递给这个 View。这也是常见的记忆化搜索, 看一下代码:
private TouchTarget mFirstTouchTarget;
/**
* 获取指定子视图的触摸目标。
* 如果没有找到,则返回null。
*/
private TouchTarget getTouchTarget(@NonNull View child) {
// 从第一个触摸目标开始遍历触摸目标链表
for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
// 如果当前触摸目标的子视图与指定的子视图相同
if (target.child == child) {
// 返回当前触摸目标
return target;
}
}
// 如果遍历完整个链表都没有找到对应的触摸目标,则返回null
return null;
}
至于这个链表是怎么构建起来的就不细说了,去看看代码,很容易就能找到啦。
2.总结与整体结构
这部分代码非常绕,涉及到实在没人消费就会向上反馈消费,需要反复琢磨才能完全理解。所以我总结了一下关键代码的位置:
public boolean dispatchTouchEvent(MotionEvent ev) {
// 省略一系列操作
// ......
if (onFilterTouchEventForSecurity(ev)) {
// 省略一系列操作
// ......
// 1.序列起点
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// 2.判断拦截
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);
} else {
intercepted = false;
}
} else {
intercepted = true;
}
// 如果事件被拦截,或者已经有视图正在处理手势,开始正常的事件分发。
// 同时,如果已经有视图正在处理手势,也进行正常的事件分发。
if (intercepted || mFirstTouchTarget != null) {
ev.setTargetAccessibilityFocus(false);
}
// 省略一系列操作
// ......
if (!canceled && !intercepted) {
// 省略一系列操作
// ......
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
// 省略一系列操作
// ......
if (newTouchTarget == null && childrenCount != 0) {
//3.准备层次数据
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
// 省略一系列操作
// ......
// 4.层次筛选
if (!child.canReceivePointerEvents()
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
// 5.定向分发
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
// 6.正常分发
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// 省略一系列操作
// ......
}
// 省略一系列操作
// ......
}
// 省略一系列操作
// ......
}
// 省略一系列操作
// ......
}
// 省略一系列操作
// ......
}
// 省略一系列操作
// ......
return handled;
}
3.判断拦截
细心的读者肯定早就发现了,上面这段代码提到的拦截部分还没有说明。这里提取出拦截部分的代码:
//判断是否拦截
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); // 如果动作被改变,则恢复原动作
} else {
intercepted = false;// 不拦截事件
}
} else {
intercepted = true;// 没有触摸目标并且动作不是初始按下,所以继续拦截触摸
}
onInterceptTouchEvent反应是否拦截。
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 检查事件是否来自鼠标
if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
// 检查事件类型是否为ACTION_DOWN(鼠标按下)
&& ev.getAction() == MotionEvent.ACTION_DOWN
// 检查是否按下了鼠标的主要按钮
&& ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
// 检查触摸位置是否在滚动条的拇指(滑块)上
&& isOnScrollbarThumb(ev.getXDispatchLocation(0), ev.getYDispatchLocation(0))) {
// 如果以上条件都满足,则拦截事件
return true;
}
// 如果条件不满足,不拦截事件
return false;
拦截在解决事件冲突中扮演着主要的角色:
- 外部拦截
if (mFirstTouchTarget == null) {
// 直接调用父容器的 onTouchEvent
handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS);
}
原理:onInterceptTouchEvent方法用于确定当前ViewGroup是否应该拦截触摸事件,可以做到不让它传递给其子视图,调用自己的onTouchEvent来进行消费。
拦截后的核心流程:终止子 View 的事件传递,后续事件直接由父容器处理,已处理事件的子 View 会收到 ACTION_CANCEL 事件。
所以只需要在适当的时候拦截一下就能解决冲突。这里可以看一下 ViewPage的横向滑动和竖直滑动的拦截源码。
- 内部拦截
原理:down事件时不拦截,给child view分发到的机会,然后由child view在合适的时机调用parent.requestDisallowInterceptTouchEvent以归还消费权,但后续也将无缘本次事件序列。如果子View需要较多的判断来决定谁来执行的话,内部拦截会是更好的选择。
内部拦截法就是通过重写底层 View 的 dispatchTouchEvent 和 onTouchEvent 方法来决定是否消费事件。
当一个子View调用requestDisallowInterceptTouchEvent(true)时,它告诉其父ViewGroup不要拦截接下来的触摸事件,即不要在onInterceptTouchEvent方法中拦截这些事件。
可以通过调用requestDisallowInterceptTouchEvent(false)来恢复父View对触摸事件的拦截。
六、onTouchEvent()
事件经过分发进入到 onTouchEvent() ,简单来说 ****onTouchEvent() ****用来处理 MotionEvent 对象,对当前的触摸事件进行处理。这部分也是重点!!!
onTouchEvent()返回 true 表示消耗触摸事件,也就是说你这次的触摸会被当前所在的 View 给消费掉,不会再继续传递给别的 View 了。
先看一下 View 类的onTouchEvent()(经过简单的筛选后的代码):
1. 获取需要的变量
// 获取触摸事件的坐标
final float x = event.getX();
final float y = event.getY();
// 获取视图的标志位
final int viewFlags = mViewFlags;
// 获取事件的动作类型
final int action = event.getAction();
final boolean clickable =
((viewFlags & CLICKABLE) == CLICKABLE // 判断是否可普通点击
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) // 判断是否可长按
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE; // 判断是否可上下文点击
2.禁用但可能消费
// 如果视图被禁用且不允许在禁用状态下点击,则更新状态并返回是否可点击
if ((viewFlags & ENABLED_MASK) == DISABLED
&& (mPrivateFlags4 & PFLAG4_ALLOW_CLICK_WHEN_DISABLED) == 0) {
if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
// 一个可点击但被禁用的视图仍然会消耗触摸事件
return clickable;
}
(viewFlags & ENABLED_MASK) == DISABLED表示被禁用,注意&&,如果没有被禁用就会直接退出 if 语句。
返回值直接就是clickable。即使视图被禁用,这个clickable可能仍然是true,意味着视图可以消耗触摸事件,但不会对事件做出响应(不会触发点击监听器)。
3.触摸事件代理机制
// 如果有触摸代理,则先让代理处理事件
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}
mTouchDelegate 是一个成员变量,用于实现 触摸事件代理机制。它的作用是允许一个视图将其触摸事件的处理委托给另一个视图或区域,常用于扩大可点击区域或将触摸事件路由到其他视图。
分析代码逻辑我们可以发现以下规则:
① 若 mTouchDelegate 存在,优先调用其 onTouchEvent()。
② 若代理对象返回 true,表示事件已消费,原始视图不再处理。
③ 若代理对象返回 false,事件继续由原始视图的 onTouchEvent() 处理。
4.根据事件动作类型进行处理
删除详细代码先看一下他的全貌:
// 如果视图可点击或有工具提示,则根据事件动作类型进行处理
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
switch (action) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_MOVE:
}
return true;
}
①ACTION_UP 触发点击事件
case MotionEvent.ACTION_UP:
// 省略一系列操作
// ......
// 如果视图处于按下状态或预按下状态,执行以下逻辑
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
// 省略一系列操作
// ......
// 如果没有执行过长按并且没有忽略下一个抬起事件,则执行点击操作
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
removeLongPressCallback(); // 移除长按回调
// 如果没有获取焦点,则执行点击操作
if (!focusTaken) {
// 创建一个Runnable对象来执行点击操作,并通过post方法延迟执行
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClickInternal(); // 如果post失败,直接执行点击操作
}
}
}
// 省略一系列操作
// ......
}
// 重置忽略下一个抬起事件标志
mIgnoreNextUpEvent = false;
break; // 结束当前case的处理
我们可以很容易的得出一个结论:点击事件是在 ACTION_UP 的时候触发。
看一下 PerformClick
private final class PerformClick implements Runnable {
@Override
public void run() {
recordGestureClassification(TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__SINGLE_TAP);
performClickInternal();
}
}
private boolean performClickInternal() {
notifyAutofillManagerOnClick();
return performClick();
}
public boolean performClick() {
// 省略一系列操作
// ......
// 检查是否存在点击监听器
if (li != null && li.mOnClickListener != null) {
// 播放点击声音效果,增强用户交互体验
playSoundEffect(SoundEffectConstants.CLICK);
// 调用点击监听器的onClick方法,并将当前视图作为参数传递
li.mOnClickListener.onClick(this);
// 如果点击监听器存在并成功调用,设置结果为true
result = true;
} else {
// 如果没有点击监听器,设置结果为false
result = false;
}
// 省略一系列操作
// ......
// 返回执行点击操作的结果
return result;
}
可以看到这里调用了onClick方法,也就是我们平时写在setOnClickListener里面的代码。
②ACTION_DOWN
简单了解一下。
case MotionEvent.ACTION_DOWN:
// 省略一系列操作
// ......
// 遍历视图层级,确定是否位于滚动容器内
boolean isInScrollingContainer = isInScrollingContainer();
// 如果视图位于滚动容器内,延迟显示按下反馈
if (isInScrollingContainer) {
// 设置预按下标志
mPrivateFlags |= PFLAG_PREPRESSED;
// 如果没有待处理的点击检查,创建一个新的CheckForTap Runnable
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
// 保存触摸位置的坐标
mPendingCheckForTap.x = event.getX();
mPendingCheckForTap.y = event.getY();
// 延迟执行CheckForTap Runnable,使用点击超时时间
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
} else {
// 如果不在滚动容器内,立即显示按下反馈
setPressed(true, x, y);
// 启动长按检查,使用长按超时时间
checkForLongClick(
ViewConfiguration.getLongPressTimeout(),
x,
y,
TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
}
break; // 结束当前case的处理
③ACTION_MOVE 滑出取消点击
当点击一个按钮但是手不松开,接下来手滑出按钮外面,再松开这个点击事件是不会触发的。
case MotionEvent.ACTION_MOVE:
// 省略一系列操作
// ......
// 对于按钮外的移动操作更加宽容
if (!pointInView(x, y, touchSlop)) {
// 如果触摸点不在视图内
// 移除任何未来的长按/点击回调
removeTapCallback();
removeLongPressCallback();
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
// 如果视图处于按下状态,则设置为非按下状态
setPressed(false);
}
// 清除手指按下的标志
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
}
// 省略一系列操作
// ......
break; // 结束case块
七、技术指导
Android 技术指导 :小王学长