12、手势与触摸:clickable、pointerInput、detectTapGestures、detectDragGestures,多点触控

手势交互,说白了就是用户和屏幕之间的「肢体语言」。你想想看,用户点一下、划一下、捏一下,App 就得知道他想干什么。在 Jetpack Compose 里,这套机制做得相当优雅。我个人觉得,理解清楚手势系统,是写出流畅交互体验的关键一步。

12.1 从 clickable 说起

最简单的点击,用 clickable 就够了。它其实是个 Modifier,加在任意 Composable 上就能响应点击。

@Composable
fun SimpleClickDemo() {
    Box(
        modifier = Modifier
            .size(120.dp)
            .background(Color.LightGray)
            .clickable { 
                // 点击回调
                println("Box 被点击了")
            }
    ) {
        Text("点我", modifier = Modifier.align(Alignment.Center))
    }
}

嗯,这里要注意:clickable 默认会加上水波纹效果(Ripple)。如果你不想要,可以传 interactionSource 参数自己控制。我在项目中遇到过一个问题——给一个 Imageclickable 后,图片区域太小,用户老点不准。后来我加上了 minimumTouchTargetSize() 来保证最小触摸区域,体验就好多了。

小技巧: clickable 其实内部封装了更底层的 pointerInput。如果你只需要简单的点击,用 clickable 就够了,代码更简洁。

12.2 pointerInput:手势的底层 API

当 clickable 满足不了你时,就该 pointerInput 出场了。它是个更底层的 Modifier,让你直接处理触摸事件流。

@Composable
fun PointerInputDemo() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Cyan)
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        val event = awaitPointerEvent()
                        // event 里包含所有触摸点的信息
                        println("触摸事件: ${event.type}")
                    }
                }
            }
    )
}

说实话,直接写 awaitPointerEventScope 有点啰嗦。所以 Compose 提供了一系列封装好的手势检测器。我个人习惯是:能用封装好的就用封装好的,实在需要原始数据才用 pointerInput。

12.3 detectTapGestures:各种点击手势

detectTapGestures 可以识别单击、双击、长按等。它比 clickable 更灵活。

@Composable
fun TapGestureDemo() {
    var text by remember { mutableStateOf("点击或长按我") }

    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Magenta)
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = { offset ->
                        text = "单击: (${offset.x.toInt()}, ${offset.y.toInt()})"
                    },
                    onDoubleTap = {
                        text = "双击"
                    },
                    onLongPress = {
                        text = "长按"
                    }
                )
            }
    ) {
        Text(text, modifier = Modifier.align(Alignment.Center))
    }
}

我曾经在做一个图片查看器时,需要区分单击和双击。单击用来显示/隐藏工具栏,双击用来放大图片。用 detectTapGesturesonTaponDoubleTap 两个回调,完美解决。但要注意一点:系统会等一段时间来判断你是不是要双击,所以单击的响应会有一丢丢延迟。嗯,这是设计上的取舍,没办法。

注意: onTap 和 onDoubleTap 同时使用时,onTap 会延迟约 300ms 触发,因为系统需要等待确认是否构成双击。如果对响应速度要求极高,建议只用一个手势。

12.4 detectDragGestures:拖拽手势

拖拽是移动端最常用的手势之一。Compose 提供了 detectDragGestures 来处理。

@Composable
fun DragGestureDemo() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    var offsetY by remember { mutableFloatStateOf(0f) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consume() // 消费事件,防止冒泡
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                }
            }
    ) {
        Box(
            modifier = Modifier
                .offset { IntOffset(offsetX.toInt(), offsetY.toInt()) }
                .size(80.dp)
                .background(Color.Green)
        )
    }
}

这里有个关键点:change.consume()。如果不调用 consume,事件会继续往下传,可能引发奇怪的问题。我刚开始学 Compose 时就踩过这个坑——拖拽一个卡片,结果底层的列表也跟着滚动了。后来加上了 consume,世界清净了。

detectDragGestures 还支持方向限制,比如只允许水平拖拽:

.pointerInput(Unit) {
    detectHorizontalDragGestures { _, dragAmount ->
        offsetX += dragAmount
    }
}

垂直拖拽同理,用 detectVerticalDragGestures

12.5 多点触控

多点触控,说白了就是同时处理多个手指。最常见的场景就是双指缩放(Pinch to Zoom)。

@Composable
fun MultiTouchDemo() {
    var scale by remember { mutableFloatStateOf(1f) }

    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Yellow)
            .pointerInput(Unit) {
                detectTransformGestures { _, _, zoom, _ ->
                    scale = (scale * zoom).coerceIn(0.5f, 3f)
                }
            }
    ) {
        Text(
            "缩放: ${String.format("%.2f", scale)}x",
            modifier = Modifier
                .scale(scale)
                .align(Alignment.Center)
        )
    }
}

detectTransformGestures 可以同时处理平移、缩放、旋转。它的回调参数有四个:centroid(中心点)、pan(平移量)、zoom(缩放比)、rotation(旋转角度)。

我在做一个白板 App 时,需要支持双指缩放画布,同时单指拖拽绘制图形。这里有个技巧:用 detectTransformGestures 处理双指,用 detectDragGestures 处理单指。但两者不能同时挂在一个 pointerInput 上,需要嵌套使用。

核心要点: 多点触控的关键在于「触摸点 ID」。每个手指按下时都有一个唯一的 ID,通过 ID 可以区分不同手指的轨迹。detectTransformGestures 内部已经帮你处理好了这些细节。

12.6 手势冲突与嵌套

手势冲突是实际开发中最头疼的问题。比如一个列表项可以点击,列表本身可以滑动,两者怎么共存?

Compose 的解决方案是「手势优先级」。默认情况下,子组件的手势优先级高于父组件。但你可以通过 pointerInputawaitPointerEventScope 手动控制事件消费。

@Composable
fun GestureConflictDemo() {
    Column(
        modifier = Modifier
            .verticalScroll(rememberScrollState())
            .pointerInput(Unit) {
                detectTapGestures(onTap = {
                    println("Column 被点击")
                })
            }
    ) {
        repeat(20) { index ->
            Text(
                text = "Item $index",
                modifier = Modifier
                    .fillMaxWidth()
                    .height(48.dp)
                    .clickable {
                        println("Item $index 被点击")
                    },
                textAlign = TextAlign.Center
            )
        }
    }
}

你猜怎么着?点击 Item 时,只会触发 Item 的 clickable,不会触发 Column 的 onTap。因为 clickable 内部消费了事件。但如果 Item 没有消费事件,事件就会冒泡到 Column。这就是 Compose 的事件分发机制。

避坑指南: 我曾经在嵌套滚动容器里同时用了 detectDragGestures 和 verticalScroll,结果拖拽手势把滚动给「吃掉」了。解决方案是:在 drag 回调里判断拖拽方向,如果是垂直方向就不消费事件,让滚动容器处理。

12.7 知识体系总览

下面这张图帮你理清本章的核心脉络:

手势与触摸知识体系 手势处理 高层封装 底层 API clickable combinedClickable 拖拽/缩放 Modifier pointerInput awaitPointerEvent 自定义手势 手势检测器 detectTapGestures detectDragGestures detectTransformGestures

12.8 总结

手势处理是 Compose 交互的核心。从简单的 clickable,到灵活的 pointerInput,再到各种 detect*Gestures 封装,Compose 给了你足够的选择空间。

我的建议是:

  • 能用 clickable 就别自己造轮子
  • 需要复杂手势时,优先用 detectTapGesturesdetectDragGestures
  • 只有需要原始触摸数据时,才用 pointerInput
  • 多点触控用 detectTransformGestures,省心省力

记住一点:手势处理的核心是「事件消费」。谁消费了事件,谁就掌握了控制权。搞懂了这一点,大部分手势冲突问题都能迎刃而解。


公众号:蓝海资料掘金营,微信deep3321