开发一个可滑动的自定义 ViewGroup 时,如何处理子 View 的触摸事件分发?

解读

国内面试中,这道题表面问“滑动冲突”,实则考察候选人能否把事件分发、拦截、消费三条链路讲清,并给出“可复用、可插拔、可测试”的落地代码。面试官通常用“如果子 View 是 RecyclerView,你左右滑、它上下滑,怎么保证不打架?”来追问,答不出“外部拦截 + 内部回调 + 速度阈值”三板斧,基本会被判“只写过业务,没写过框架”。

知识点

  1. 事件分发三剑客:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent
  2. 滑动冲突两类:外部滑动与内部滑动方向一致、方向相反
  3. 外部拦截法(推荐):在 onInterceptTouchEvent 中根据位移、速度、阈值决定是否拦截
  4. 内部回调法:子 View 通过 requestDisallowInterceptTouchEvent 反向干预
  5. VelocityTracker + Scroller/OverScroller 计算速度与惯性
  6. 边缘拖拽、多点触控、无障碍 TalkBack 的兼容策略
  7. 单元测试:使用 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 完整消费事件。

步骤五:边缘拖拽与无障碍

  1. 在 onInterceptTouchEvent 里增加边缘阈值判断,防止误触
  2. 重写 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 可集成。

拓展思考

  1. 折叠屏展开后,屏幕变宽,横向滑动阈值是否需要动态调整?
    答:在 onConfigurationChanged 中根据新的 widthPixels 重新计算 touchSlop 比例,避免误触或拖不动。

  2. 子 View 是 ViewPager2,也想要横向滑,如何共存?
    答:采用“嵌套滑动机制”,父 View 实现 NestedScrollingParent3,子 View 实现 NestedScrollingChild3,通过 NestedScrollingParentHelper 分发,优先让子 View 消费,剩余距离再由父 View 消费,实现“手风琴”式衔接。

  3. 业务要求“滑动到边界继续拖时,父 View 拉出抽屉”,如何设计?
    答:在 onTouchEvent 的 ACTION_MOVE 中检测 overScroll,当 scrollX < 0 或 > maxScrollX 时,手动 dispatchTouchEvent 给抽屉父,实现“边界传递”效果,同时记录嵌套偏移量,保证 up 事件坐标一致。