开发一个可滑动的自定义 ViewGroup 时,如何处理子 View 的触摸事件分发?
解读
国内面试中,这道题表面问“滑动冲突”,实则考察候选人能否把事件分发、拦截、消费三条链路讲清,并给出“可复用、可插拔、可测试”的落地代码。面试官通常用“如果子 View 是 RecyclerView,你左右滑、它上下滑,怎么保证不打架?”来追问,答不出“外部拦截 + 内部回调 + 速度阈值”三板斧,基本会被判“只写过业务,没写过框架”。
知识点
- 事件分发三剑客:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent
- 滑动冲突两类:外部滑动与内部滑动方向一致、方向相反
- 外部拦截法(推荐):在 onInterceptTouchEvent 中根据位移、速度、阈值决定是否拦截
- 内部回调法:子 View 通过 requestDisallowInterceptTouchEvent 反向干预
- VelocityTracker + Scroller/OverScroller 计算速度与惯性
- 边缘拖拽、多点触控、无障碍 TalkBack 的兼容策略
- 单元测试:使用 Robolectric 模拟 MotionEvent,验证拦截逻辑分支覆盖率
答案
步骤一:明确冲突场景
假设自定义 ViewGroup 支持横向滑动切换卡片,内部子 View 是纵向 RecyclerView,两者方向垂直,属于“方向相反”冲突,优先采用“外部拦截法”。
步骤二:重写 onInterceptTouchEvent
private var initialX = 0f
private var initialY = 0f
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private val velocityTracker = VelocityTracker.obtain()
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
initialX = ev.x
initialY = ev.y
velocityTracker.clear()
velocityTracker.addMovement(ev)
// 先不拦截,让子 View 收到 DOWN
return false
}
MotionEvent.ACTION_MOVE -> {
velocityTracker.addMovement(ev)
val dx = abs(ev.x - initialX)
val dy = abs(ev.y - initialY)
// 角度小于 30° 认为是横向滑动
if (dx > touchSlop && dx * 0.577f > dy) {
parent.requestDisallowInterceptTouchEvent(true)
return true // 拦截后续事件,自身消费
}
return false
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
velocityTracker.clear()
return false
}
}
return false
}
步骤三:在 onTouchEvent 中处理滑动
override fun onTouchEvent(event: MotionEvent): Boolean {
velocityTracker.addMovement(event)
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
val deltaX = (initialX - event.x).toInt()
scrollBy(deltaX, 0)
initialX = event.x
}
MotionEvent.ACTION_UP -> {
velocityTracker.computeCurrentVelocity(1000)
val vx = velocityTracker.xVelocity
if (abs(vx) > 800) {
// 惯性滑动
scroller.fling(scrollX, 0, -vx.toInt(), 0,
0, maxScrollX, 0, 0)
postInvalidateOnAnimation()
}
}
}
return true
}
步骤四:兼容子 View 反向干预
若子 View 内部也有横向滑块(如 Banner),可在子 View 的 onTouchEvent 里检测到自身需要横向滑时,调用
parent.requestDisallowInterceptTouchEvent(true)
此时父 View 的 onInterceptTouchEvent 不会再触发,保证子 View 完整消费事件。
步骤五:边缘拖拽与无障碍
- 在 onInterceptTouchEvent 里增加边缘阈值判断,防止误触
- 重写 dispatchPopulateAccessibilityEvent 与 onInitializeAccessibilityNodeInfo,声明“可滑动”语义,确保 TalkBack 用户双指滑动触发翻页而非触摸探索
步骤六:单元测试
@Test
fun `when horizontal move exceed touchSlop, should intercept`() {
val group = CardSlideLayout(context)
val recyclerView = RecyclerView(context)
group.addView(recyclerView)
val down = MotionEvent.obtain(0, 0, ACTION_DOWN, 10f, 10f, 0)
val move = MotionEvent.obtain(0, 0, ACTION_MOVE, 30f, 12f, 0)
assertFalse(group.onInterceptTouchEvent(down))
assertTrue(group.onInterceptTouchEvent(move))
}
用 Robolectric 本地跑,无需真机,CI 可集成。
拓展思考
-
折叠屏展开后,屏幕变宽,横向滑动阈值是否需要动态调整?
答:在 onConfigurationChanged 中根据新的 widthPixels 重新计算 touchSlop 比例,避免误触或拖不动。 -
子 View 是 ViewPager2,也想要横向滑,如何共存?
答:采用“嵌套滑动机制”,父 View 实现 NestedScrollingParent3,子 View 实现 NestedScrollingChild3,通过 NestedScrollingParentHelper 分发,优先让子 View 消费,剩余距离再由父 View 消费,实现“手风琴”式衔接。 -
业务要求“滑动到边界继续拖时,父 View 拉出抽屉”,如何设计?
答:在 onTouchEvent 的 ACTION_MOVE 中检测 overScroll,当 scrollX < 0 或 > maxScrollX 时,手动 dispatchTouchEvent 给抽屉父,实现“边界传递”效果,同时记录嵌套偏移量,保证 up 事件坐标一致。