Compose 手势系统:PointerInput 事件管道与嵌套滚动冲突解决

把一个旧项目从 View 体系迁移到 Compose 时,第一个让我头疼的 Bug 是列表嵌套横向滑动条目——手指稍微倾斜,纵向列表和横向子项就开始粘连抖动。View 体系里 requestDisallowInterceptTouchEvent 一行搞定的事,到了 Compose 直接不认了。

Compose 的手势系统不是 View 触摸分发的语法糖包装,而是一套独立架构。花了两周把相关源码翻完,整理出这篇。

从 View 触摸分发到 Compose 事件管道

View 体系的手势处理依赖 onInterceptTouchEventonTouchEvent 的递归链。这套机制的死穴在于:拦截决策和事件执行耦合在一个方法里,父 View 必须在收到事件的那一刻同步拍板要不要拦截。结果就是嵌套滚动的逻辑散落到各个层级,改一处牵动全身。

Compose 做了一个根本性的拆分:把命中测试(Hit Testing)、事件消费、手势检测三层彻底解耦

事件进入 Compose 后的流转路径:

原生 MotionEvent → PointerInteropFilter → LayoutNode 命中测试
→ PointerInputFilter 管道 → 手势检测器(detectDragGestures 等)

第一层 PointerInteropFilter 把 Android 原生 MotionEvent 转成 Compose 内部的 PointerEvent。第二层命中测试根据触点坐标定位对应的 LayoutNode 树。第三层 PointerInputFilter 管道才是我们通过 Modifier.pointerInput 注册的逻辑队列。

PointerInputFilter:事件管线

pointerInput 修饰符背后,Compose 会创建一个 PointerInputFilter 挂到 LayoutNode 上。核心实现:

// androidx.compose.ui.input.pointer.PointerInteropFilter
internal class PointerInputFilter(
    private val layoutNode: LayoutNode,
    private val pointerInputHandler: PointerInputEventHandler
) {
    fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize
    ) {
        // 根据 pass 阶段分派事件
        pointerInputHandler.invoke(pointerEvent, pass, bounds)
    }
}

PointerEventPass 这个参数经常被忽略,但它是理解整个手势系统的关键。它定义了事件在 Modifier 链上传递的三个阶段:

  • Initial:自顶向下传递,父节点先于子节点收到,做拦截决策
  • Main:核心手势处理阶段,绝大多数检测在这里完成
  • Final:自底向上传递,子节点先于父节点收到,做收尾清理

同一事件在同一帧内被不同 Modifier 按优先级依次处理,而不是 View 体系里”拦截 or 不拦截”的一锤子买卖。这个设计是嵌套滚动冲突解决方案的基石。

声明式手势 API 的内部实现

Compose 提供了三组手势检测 API:detectTapGesturesdetectDragGesturesdetectTransformGestures。它们都依赖 AwaitPointerEventScope,一个允许你在 pointerInput 块内挂起等待特定事件序列的协程作用域。

点击检测:awaitFirstDown 的状态机

detectTapGestures 内部是一个状态机,精简后的逻辑:

suspend fun PointerInputScope.detectTapGestures(
    onTap: ((Offset) -> Unit)? = null,
    onDoubleTap: ((Offset) -> Unit)? = null,
    onLongPress: ((Offset) -> Unit)? = null,
) {
    awaitPointerEventScope {
        while (true) {
            val down = awaitFirstDown(requireUnconsumed = false)
            val upOrDrag = withTimeoutOrNull(
                viewConfiguration.longPressTimeoutMillis
            ) {
                waitForUpOrCancellation()
            }
            if (upOrDrag != null) {
                // 在长按超时前抬手 → 判定为 tap
                onTap?.invoke(upOrDrag.position)
            } else {
                // 超时仍未抬手 → 进入长按分支
                onLongPress?.invoke(down.position)
                waitForUpOrCancellation()
            }
        }
    }
}

这里踩过一个坑:requireUnconsumed = falseawaitFirstDown 默认只响应未被消费的事件,如果上游 Modifier 已经消费了 Down 事件,你的检测器直接拿不到事件。做全局埋点这种需要”穿透监听”的场景,必须显式传 false,否则事件链路在中间就断了。这个问题在官方文档里一笔带过,调试时花了不少时间。

拖拽检测:事件消费与互斥

detectDragGestures 启动后循环接收 Move 事件,把位移量传给 onDrag

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
    awaitPointerEventScope {
        val down = awaitFirstDown()
        onDragStart(down.position)
        var currentPointer = down
        while (true) {
            val event = awaitPointerEvent()
            val dragChange = event.changes.firstOrNull() ?: break
            if (dragChange.pressed) {
                dragChange.consume() // 关键:消费事件以阻止穿透
                val dragAmount = dragChange.position - currentPointer.position
                onDrag(dragChange, dragAmount)
                currentPointer = dragChange
            } else {
                onDragEnd()
                break
            }
        }
    }
}

dragChange.consume() 是解决嵌套冲突的核心操作:子组件消费事件后,父组件的 pointerInput 在同阶段 pass 中不会再收到该事件。

但如果父组件在 Initial 阶段抢先消费了呢?

嵌套滚动冲突的解法

Compose 对嵌套滚动的处理比 View 体系清爽一个量级:用 nestedScrollpointerInput 的 Pass 阶段组合来解决,而不是靠 requestDisallowInterceptTouchEvent 这种父子的反向通知。

典型场景”横向滑动子项 + 纵向滚动列表”,标准方案是:

  1. 横向子项在 Main pass 注册 detectHorizontalDragGestures
  2. 纵向列表在 Initial pass 注册 detectVerticalDragGestures

垂直滑动时,Initial pass 的纵向检测器先收到事件并消费,横向子项在 Main pass 无事件可收;横向滑动同理。

但实际项目里纯 pass 分离不够。用户斜向滑动时,两个方向的位移都超过阈值,结果两边一起响应,画面抖动。我的解法是加了一层方向锁定:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val down = awaitFirstDown()
        var directionLocked = false
        var lockedAxis: Axis? = null
        while (true) {
            val event = awaitPointerEvent()
            val change = event.changes.firstOrNull() ?: break
            val delta = change.position - down.position
            if (!directionLocked && (abs(delta.x) > touchSlop || abs(delta.y) > touchSlop)) {
                lockedAxis = if (abs(delta.x) > abs(delta.y)) Axis.Horizontal else Axis.Vertical
                directionLocked = true
            }
            if (lockedAxis == Axis.Horizontal) {
                // 处理横向拖拽
                change.consume()
            }
        }
    }
}

思路很直白:Initial pass 中判断滑动主方向并锁定轴向,Main pass 按锁定方向分发事件。手指斜向移动也只响应主方向,彻底杜绝抖动。

Transform 检测的旋转缩放陷阱

detectTransformGestures 能同时识别平移、旋转、缩放,方便,但容易踩坑。它通过计算两个触点的位置变化推算变换参数:

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitFirstDown()
            do {
                val currentEvent = awaitPointerEvent()
                val changes = currentEvent.changes
                if (changes.size >= 2) {
                    val centroid = changes.calculateCentroid()
                    // 相对于上一帧计算平移、缩放、旋转增量
                    onGesture(centroid, pan, zoom, rotation)
                }
            } while (changes.any { it.pressed })
        }
    }
}

问题出在单指切换:用户双指缩放后抬起一根手指,剩余的单指被当成平移处理,画面突然跳动。修复方式是在 onGesture 回调中根据触点数量过滤:

onGesture = { centroid, pan, zoom, rotation ->
    if (changes.size >= 2) {
        // 双指:执行缩放旋转
        scale *= zoom
    }
    // 无论几指都执行平移
    offset += pan
}

项目中的几点经验

三个项目迭代下来的感受:

  • 事件消费时机要精确。不消费→嵌套冲突;过早消费→父组件无法协同。在 onDragStart 回调中消费比 awaitFirstDown 阶段稳妥得多,因为此时方向已经确定。
  • 惯性动画用 velocity 参数,别自己算detectDragGestures 结束时的 onDragEnd 回调自带速度估算,配合 Compose 的 animateDecay,效果远好于手写的衰减曲线。我在一个图片浏览组件上把手动衰减换成 animateDecay 后,滑动跟手性提升明显。
  • 复杂手势优先组合而非叠加。不要在一个 pointerInput 块里塞多个 detectXxx。用 Modifier.pointerInput 链式挂载多个独立的手势检测器,职责分离后调试和复用都轻松很多。修改某个手势行为时不会误伤其他逻辑,这是模块化原则在 Compose 手势层的直接应用。

延伸阅读