三大法则(牢记)
-
谁消费了 DOWN,后续 MOVE/UP 就归谁
- 首个 ACTION_DOWN 决定“触摸目标”(TouchTarget)。
- 期间若父容器后来拦截,原目标会收到 ACTION_CANCEL,归属改为父容器。
-
父容器可在任意时机拦截;子可“禁止后续拦截”
- ViewGroup#onInterceptTouchEvent(ev):true=拦截并自己处理;false=分发给子。
- requestDisallowInterceptTouchEvent(true):让父容器在本次序列中不再拦截(对 DOWN 无效,DOWN 到来时标志会被清空,生效于后续 MOVE/UP)。
-
监听优先级与兜底
- OnTouchListener#onTouch() 优先于 onTouchEvent();true 直接消费。
- 若所有子 View 都未消费,事件回落到父容器自己的 onTouchEvent();再不消费一路冒泡到 Activity#onTouchEvent()。
分发总链路(顶→底)
Activity#dispatchTouchEvent() → Window#superDispatchTouchEvent() → DecorView →
ViewGroup#dispatchTouchEvent() (可能 onInterceptTouchEvent())→ 子 View#dispatchTouchEvent() →
子 OnTouchListener → 子 onTouchEvent()
(若子未消费:返回父容器 onTouchEvent() → 再不消费:回到 Activity 的 onTouchEvent())
必修 API 与“返回值影响”
1)ViewGroup#onInterceptTouchEvent(ev): Boolean
-
true:本次事件(及后续)改由父容器自身处理;若是中途拦截,会先给原目标一个 CANCEL。
-
false:把事件下发给子。
-
常用:在 MOVE 中基于“滑动阈值”决定是否拦截(外部拦截法)。
2)View#dispatchTouchEvent(ev): Boolean
(简化伪码)
fun dispatchTouchEvent(ev: MotionEvent): Boolean {
// 监听优先级最高;需要 view 已启用
if (mOnTouchListener != null && isEnabled && mOnTouchListener.onTouch(this, ev)) {
return true
}
return onTouchEvent(ev) // 否则走默认触摸处理
}
3)View#setOnTouchListener(...)/onTouch(View, ev): Boolean
-
返回 true:直接消费,不再进入该 View 的 onTouchEvent()。
-
返回 false:交给 onTouchEvent() 继续处理。
-
典型用途:轻量手势、拦下特定阶段(如 DOWN)以改变归属。
4)View#onTouchEvent(ev): Boolean(默认规则)
-
若 isClickable || isLongClickable || isContextClickable:一般会消费(true),并维护 pressed、点击/长按逻辑。
-
否则通常返回 false,把机会留给父容器。
-
enabled=false 的 View 默认不消费触摸(但仍可作为命中目标,常见于禁用控件让父处理)。
5)CANCEL的意义
- 表示当前手势被上层“终止”(父拦截、系统打断、窗口切换等);收到 CANCEL 必须停止跟踪并复位 UI 状态(取消按压、高亮、滚动等)。
ViewGroup 分发流程(关键片段伪码)
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val action = ev.actionMasked
if (action == ACTION_DOWN) clearTouchTargets()
// 1) 是否允许父拦截(受 requestDisallowIntercept 影响)
val intercepted = if (!disallowIntercept) onInterceptTouchEvent(ev) else false
// 2) 未拦截 → 向子分发(命中检测:从最上层子开始倒序查找)
if (!intercepted && action == ACTION_DOWN) {
for (child in childrenReversed) {
if (child.isVisible && child.hit(ev)) {
if (child.dispatchTouchEvent(ev)) {
setTouchTarget(child) // 这个 child 消费了 DOWN
return true
}
}
}
} else if (!intercepted && hasTouchTarget()) {
// 后续事件直接发给“已确定的目标 child”
return targetChild.dispatchTouchEvent(ev)
}
// 3) 自己处理(含:被动兜底 或 主动拦截)
return super.dispatchTouchEvent(ev) // → 进入本 ViewGroup 的 OnTouchListener/onTouchEvent
}
“后续事件归属”与requestDisallowInterceptTouchEvent(true)
-
默认:父容器在后续 MOVE 有权“改主意”→ onInterceptTouchEvent() 返回 true → 子收到 CANCEL。
-
子通知父: requestDisallowInterceptTouchEvent(true)
- 作用:让父容器跳过 onInterceptTouchEvent() 判定(视为不拦截)。
- 生效范围:当前触摸序列(从 DOWN 到 UP/CANCEL);下一次新 DOWN 会自动清空该标志。
- 常用于:横竖滑动冲突中,子控件在判断“自己要处理”后立刻调用,避免父容器“半路截胡”。
常见滑动冲突两大范式
1) 外部拦截法(父决策)
- 父容器在 MOVE 中根据位移方向/阈值决定:
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
ACTION_DOWN -> { lastX=ev.x; lastY=ev.y; intercept=false }
ACTION_MOVE -> {
val dx = ev.x - lastX; val dy = ev.y - lastY
intercept = abs(dx) > abs(dy) && abs(dx) > touchSlop // 例如:横向滑动我拦
}
else -> { }
}
return intercept
}
2) 内部拦截法(子决策)
-
子控件优先处理,必要时放权给父:
-
子在 DOWN 先 requestDisallowInterceptTouchEvent(true)。
-
当检测到“应交给父”的条件(如横向位移大)时,子取消禁止(对父来说等同下次判断返回 true),父随后拦截并接管。
-
这种方式常见于:RecyclerView 嵌 ViewPager2、地图控件内的滚动等。
-
注:更现代的体系是 嵌套滚动(NestedScrollingParent/Child 2/3) ,通过 onStartNestedScroll/onNestedPreScroll/onNestedScroll 精细协同,少用粗暴拦截。
典型行为清单(快查表)
场景 | 返回值/调用点 | 结果 |
---|---|---|
子 onTouch() 返回 true | 子立即消费 | 不再进子 onTouchEvent() |
子 onTouch() 返回 false 且 onTouchEvent() 返回 true | 子消费 | 成为 TouchTarget,后续归子 |
所有子都 false | 父 onTouchEvent() 有机会 | 父若也 false,冒泡到 Activity |
父在后续 MOVE 里 onInterceptTouchEvent() → true | 父接管 | 子先收 CANCEL,后续归父 |
子调用 requestDisallowInterceptTouchEvent(true) | 父不再拦截(本次序列) | 但 DOWN 阶段无效,下个序列自动失效 |
CANCEL 到来 | 子需终止手势、复位 UI | 不触发点击/长按回调 |
调试与排错技巧
- 在相关 dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent 里打印:ev.actionMasked、坐标、this。
- 打开开发者选项「显示点按」看触点路径;用 GestureDetector 验证点击/长按阈值是否触发。
- 确保容器是否 clickable:可点击容器会倾向于消费 DOWN;纯容器若不想抢事件,去掉 android:clickable="true"。
- 复杂嵌套优先考虑 NestedScrolling 协议,少用“强拦截”。
面试/实战高频问两句(顺嘴能答)
-
为什么 requestDisallowInterceptTouchEvent(true) 对 DOWN 无效?****
因为新序列开始时父容器会清空该标志,框架需先让父有机会决定是否把 DOWN 分给子。
-
OnTouchListener 与 OnClickListener 的关系?****
onTouch() 若消费了 DOWN/MOVE/UP(返回 true),可能导致点击手势条件不成立,从而阻断 onClick()。