前言
自定义触摸反馈其实就是去重写 onTouchEvent()
方法,在方法内部定制触摸反馈算法。
例如,我们可以重写该方法,来粗略地判断用户的点击。
kotlin
class TouchView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
override fun onTouchEvent(event: MotionEvent): Boolean {
// 如果是抬起事件, 认为一次点击
if (event.actionMasked == MotionEvent.ACTION_UP) {
// 触发点击事件
performClick()
}
return true
}
}
在上述代码中,getActionMasked()
方法用于获取触摸事件的类型。如果是抬起,就会调用 performClick()
方法触发点击,在方法内部会执行设置点击监听器时传入的 OnClickListener
实例的 onClick(view)
方法。
我们也可以通过 getAction()
方法来获取触摸事件的类型。两者的区别在于 getAction()
返回值混合 了事件类型和进行操作的手指索引,就比如 event.getAction() == MotionEvent.ACTION_POINTER_DOWN
的比较永远为 false
。而 getActionMasked()
在内部会返回真正的事件类型,用于多点触控。
方法的返回值为 true
,表示当前 View 消费了该组事件。注意:触摸事件是一个序列,是以组来划分的,序列以按下事件开始,以抬起或取消事件结束。
为什么要这样?
将此次点击事件 (一组触摸事件) 标记为已消费,可以阻止事件向上传递给父 View 的 onTouchEvent()
,避免了一次点击触发多个 View 响应的问题。
但事件还是会传递给父 View 的
dispatchTouchEvent()
,父 View 在分发事件时,会直接传给消费了事件的子 View,而不会调用自己的onTouchEvent()
。
另外,决定是否消费只有在按下事件返回时才有效,之后的事件中返回无意义。所以,我们可以用下面这行代码替换之前的 return true
,效果是等价的。
kotlin
return event.actionMasked == MotionEvent.ACTION_DOWN
View.onTouch 源码解析
我们来看看 View 的 onTouchEvent()
方法。
java
final float x = event.getX();
final float y = event.getY();
final int viewFlags = mViewFlags;
final int action = event.getAction();
这部分是获取触摸的坐标、当前视图的标记位,以及触摸事件的类型。
java
final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
clickable
表示当前 View 是否可被点击,CLICKABLE
表示点击,LONG_CLICKABLE
表示长按,CONTEXT_CLICKABLE
表示上下文点击,就是鼠标右键或是手指长按空白区域,通常会弹出一个上下文菜单,而这种点击就是上下文点击。
java
// 如果是禁用状态且在当前状态下不可点击
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;
// A disabled view that is clickable still consumes the touch
// events, it just doesn't respond to them.
// 返回 clickable
return clickable;
}
这段代码用于让处于禁用状态下的 View 被点击后,能够消费触摸事件。让其下层的 View 不能获取到触摸事件,也就是不能被点击。
java
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}
上述代码中设置 mTouchDelegate
(触摸代理),mTouchDelegate
用于增大可点击区域。
前面的代码就看完了,接着看核心代码,首先是最外层的 if 判断。
java
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
switch (action) {
// ...
}
}
其中 TOOLTIP
是工具提示的标志,我们可以给 View 设置工具提示,这样长按该 View 会显示一段提示,解释该 View 的作用。
只要满足任一条件,可点击或是设置了工具提示,就能够进入触摸事件的处理逻辑。
然后看看每一条分支,按照触摸事件的顺序来,先看 MotionEvent.ACTION_DOWN
。
MotionEvent.ACTION_DOWN
java
if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
mPrivateFlags3 |= PFLAG3_FINGER_DOWN;
}
这一段代码用于获取是否手指触摸到了屏幕,因为在早期的安卓手机和电视上,是可以通过实体按键来操作的。它的作用是当手指触摸时,让提示文字隔开一段距离,不至于手指会挡住提示。
java
if (!clickable) {
checkForLongClick(
ViewConfiguration.getLongPressTimeout(),
x,
y,
TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
break;
}
如果当前 View 不可点击,就去检测长按,给长按设置一个等待器(延迟触发操作)。方法内部会在长按检测时间到达后,执行长按操作(performLongClick(x,y)
)。
java
if (performButtonActionOnTouchDown(event)) {
break;
}
performButtonActionOnTouchDown()
方法用于处理鼠标右键操作,方法内部会弹出上下文菜单。
java
// Walk up the hierarchy to determine if we're inside a scrolling container.
boolean isInScrollingContainer = isInScrollingContainer();
// For views inside a scrolling container, delay the pressed feedback for
// a short period in case this is a scroll.
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);
}
break;
其中 isInScrollingContainer()
方法用于判断当前 View 是否处于可滚动的容器中。
java
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public boolean isInScrollingContainer() {
ViewParent p = getParent();
while (p != null && p instanceof ViewGroup) {
if (((ViewGroup) p).shouldDelayChildPressedState()) {
return true;
}
p = p.getParent();
}
return false;
}
方法内部会不断递归调用父 ViewGroup 的 shouldDelayChildPressedState()
来获取是否延迟子 View 的按下状态值,只要有任何一个父 ViewGroup 返回 true
,就直接返回。
ViewGroup 的
shouldDelayChildPressedState()
方法默认实现总是返回 true。
获取到该值后,就会进行判断:
-
不在滚动容器中,将当前设为按下状态,并开启长按检测。
-
在滚动容器中 ,会先将当前 View 标记设为预按下状态,然后设置一个按下的等待器。等待时间到达后,才会取消预按下状态,设置为按下状态 (会显示按下效果,比如颜色会变深),并开始检测长按。
这样可以防止与父 ViewGroup 的滚动发生冲突。一个典型的例子是,滑动QQ的聊天列表时,列表项并不会有点击效果,只有当缓慢滑动时,列表项才会出现按下状态效果,这是因为按下的时长超过了 TAP_TIMEOUT
(100ms)。
因此,如果我们自定义一个不支持滚动的 ViewGroup,需要去重写 shouldDelayChildPressedState()
方法并返回 false
,这样可以避免子 View 在点击时的延迟。
MotionEvent.ACTION_MOVE
再来看看 MotionEvent.ACTION_MOVE
,它的代码会更复杂一些,我们分段来看。
java
// Be lenient about moving outside of buttons
if (!pointInView(x, y, touchSlop)) {
// Outside button
// Remove any future long press/tap checks
removeTapCallback();
removeLongPressCallback();
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
}
这段代码用于判断当前触摸点是否超出 View 的边界。如果超出,就会移除点击和长按的延迟回调,并取消按下状态。其中 touchSlop
是一个溢出的阈值,作用是扩大 View 的可触摸边界,提供容错率。
java
final int motionClassification = event.getClassification();
// ... 省略模糊手势处理
final boolean deepPress =
motionClassification == MotionEvent.CLASSIFICATION_DEEP_PRESS;
if (deepPress && hasPendingLongPressCallback()) {
// process the long click action immediately
removeLongPressCallback();
checkForLongClick(
0 /* send immediately */,
x,
y,
TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__DEEP_PRESS);
}
break;
另外,还会处理 Android 10 开始支持的深度按压(Deep Press)和模糊手势(Ambiguous Gesture),通过复杂的超时和边界判断,让手势识别更加精准。
MotionEvent.ACTION_UP
java
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
// ...
}
// 是否忽略下次抬起事件
mIgnoreNextUpEvent = false;
break;
ACTION_UP
的核心逻辑只有在当前是按下 或是预按下状态时,才会进入。
java
// take focus if we don't have it already and we should in
// touch mode.
boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
focusTaken = requestFocus();
}
首先,如果需要,会处理焦点获取的逻辑。
java
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);
}
如果当前为预按下状态(在滚动容器中快速点击并抬手),为了用户能够看到效果,会立即设置为按下状态。
java
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(); // 同步执行
}
}
}
在这之后,没有触发长按操作,就会移除长按检测,并异步触发点击操作。
java
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();
}
最后取消按下状态。如果是当前是预按下状态,会延迟取消,让用户能感知到短暂的按下效果。
MotionEvent.ACTION_CANCEL
ACTION_CANCEL
分支的代码全在做收尾工作。用于在事件被中断时,取消按下状态、移除所有回调、重置标记位。
ViewGroup.onIntercreptTouchEvent()
前面我们说,当一个子 View 消费过触摸事件后,它的父 View 就不会在 onTouchEvent()
中获取到这一组触摸事件。但有时,父 View 需要强行获取触摸事件的控制权。最典型的场景就是滑动容器:它会先将触摸事件给子 View,让子 View 能够响应点击;然后在用户滑动时,夺取事件的控制权,让自己滚动。
父 ViewGroup 是通过 onIntercreptTouchEvent()
方法来完成这个决策的。在一个事件序列传递过程中,每一个事件都会经过父 ViewGroup 的这个方法。
-
如果方法返回 false,表示不拦截,会将事件传递给子 View;
-
否则,决定 拦截事件。后续的所有事件将不会传递给子 View,父 ViewGroup 将会调用
onTouchEvent()
方法进行处理,且不再经过onInterceptTouchEvent()
。
同时,之前正在处理事件的子 View 将会接收到 MotionEvent.ACTION_CANCEL
事件,告知子 View 事件序列已被父 View 接收。
一般自定义 ViewGroup 的策略:在
ACTION_DOWN
事件到来时不拦截,让子 View 有机会处理事件。在ACTION_MOVE
中,判断如果需要时,才会返回true
进行拦截。
通常在父 View 拦截之前,ACTION_DOWN
事件已经传给子 View,所以需要在 onIntercreptTouchEvent()
方法中完成准备工作,比如保存初始按下的坐标。
另外,onIntercreptTouchEvent()
和 onTouchEvent()
,都是由 dispatchTouchEvent()
来调度的,Activity 获取到触摸事件后,也是通过 dispatchTouchEvent()
将事件传递下来的。这个过程是这样的:
dispatchTouchEvent() 源码分析
View
View 的 dispatchTouchEvent()
的核心逻辑就一行,调用 onTouchEvent(event)
方法来处理事件,其余都是些前置检查。
ViewGroup
ViewGroup 的 dispatchTouchEvent()
代码很长,我们先梳理一下流程和伪代码:
会判断是否需要进行拦截,是的话,就调用 onTouchEvent()
方法处理触摸事件,并消费该组事件;反之,调用子 View 的 dispatchTouchEvent()
方法,传递该组事件。
java
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
// 检查是否要进行拦截
if (onInterceptTouchEvent(event)) {
// 如果拦截,事件交给自己处理
result = onTouchEvent(event); // 实际上通过super.dispatchTouchEvent(event)来调用
} else {
// 如果不拦截,将事件分发给子View
result = child.dispatchTouchEvent(event);
}
return result;
}
注意:在分发事件之前,会遍历子 View,判断触摸点位于哪个子 View 上;还会将相对于 ViewGroup
的坐标转为相对于子 View 的坐标,这个过程会通过 dispatchTransformedTouchEvent()
方法来完成。
接着我们来看源码的关键部分。
第一段为:
java
// 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();
}
如果是 ACTION_DOWN
事件,会清除之前所有的触摸目标,重置触摸状态。
在 resetTouchState()
方法中,会清除 FLAG_DISALLOW_INTERCEPT
标记,与之相关的方法为 requestDisallowInterceptTouchEvent()
。
requestDisallowInterceptTouchEvent()
用于解决嵌套滑动冲突,请求父 View 不要拦截事件。重置这个标记,能够让父 View 在下一次事件序列中,恢复拦截能力。
第二段代码为:
java
// Check for interception.
final boolean intercepted;
ViewRootImpl viewRootImpl = getViewRootImpl();
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
final boolean isBackGestureInProgress = (viewRootImpl != null
&& viewRootImpl.getOnBackInvokedDispatcher().isBackGestureInProgress());
if (!disallowIntercept || isBackGestureInProgress) {
// Allow back to intercept touch
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;
}
这部分是拦截的核心逻辑。
-
外层判断:如果当前是
ACTION_DOWN
事件(事件序列开端),或是mFirstTouchTarget
不为空(已有子 View 在处理这个事件序列),ViewGroup 才会考虑是否拦截,否则直接拦截。 -
内层判断:会检查子 View 是否请求 ViewGroup 不要拦截事件和系统是否正在处理全局返回手势,如果子 View 没有禁止拦截,或是系统在处理返回手势,ViewGroup 就会判断是否拦截,否则,听从子 View 的请求,不进行拦截。
补充:一个子 View 消费了事件,就会被 ViewGroup 记录为一个触摸目标,触摸目标使用链表存储,上述的 mFirstTouchTarget
为表头。
第三段代码为:
在确定不拦截事件后,就开始为新的触摸点寻找触摸目标,这个过程只在手势的开始或是多点触控中新手指按下时才会触发。
java
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) {
// ...
// 遍历子 View
for (int i = childrenCount - 1; i >= 0; i--) {
// ...
// 判断当前子 View 是否已是触摸目标
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
// 如果是,新增手指 ID
newTouchTarget.pointerIdBits |= idBitsToAssign;
// 结束遍历
break;
}
// ...
// 尝试分发事件给这个子 View
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// ...
// 如果愿意消费,新增为新的触摸目标
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
// 结束遍历
break;
}
// ...
}
// ...
}
// ...
}
}
这段代码会遍历所有子 View,询问是否愿意处理事件。其中关键在于 dispatchTransformedTouchEvent()
方法,它会将事件分发给子 View,如果方法返回 true
,说明子 View 消费了事件。ViewGroup 会调用 addTouchTarget()
将其加入链表中,并停止遍历,因为任务已完成。
如果询问之前,子 View 已经是触摸目标,说明用户的第二根手指点在了同一个 View 上。这时,只需更新手指信息到已有的 TouchTarget
上即可,无需再次分发。
第四段代码为:
如果手势已经开始,ViewGroup 就会将后续的事件分发给已知的触摸目标。
java
// Dispatch to touch targets.
// 如果没有找到触摸目标,比如点到 ViewGroup 的空白区域,就自己处理事件 (调用自己的 onTouchEvent())
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 {
// 是否需要发送 CANCEL 事件
final boolean cancelChild =
(target.child != null && resetCancelNextUpFlag(target.child))
|| intercepted;
// 事件分发给目标子 View
if (target.child != null && dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
// 发送了 CANCEL,从链表中移除
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
if (!target.isRecycled()) {
target.recycle();
}
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
如果没有触摸目标,ViewGroup 就会将事件分发给自己,调用自己的 onTouchEvent()
方法进行处理。
分发时,会遍历触摸目标链表,通过 dispatchTransformedTouchEvent()
分发事件给链表中的每一个 View。
如果 ViewGroup 在中途决定拦截事件 (intercepted
为 true
),cancelChild
标记就会生效,事件会被转为 ACTION_CANCEL
发给子 View,然后这个触摸目标就会被移除。
至此,ViewGroup 的 dispatchTouchEvent()
的核心逻辑就讲完了。