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 参数自己控制。我在项目中遇到过一个问题——给一个 Image 加 clickable 后,图片区域太小,用户老点不准。后来我加上了 minimumTouchTargetSize() 来保证最小触摸区域,体验就好多了。
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))
}
}
我曾经在做一个图片查看器时,需要区分单击和双击。单击用来显示/隐藏工具栏,双击用来放大图片。用 detectTapGestures 的 onTap 和 onDoubleTap 两个回调,完美解决。但要注意一点:系统会等一段时间来判断你是不是要双击,所以单击的响应会有一丢丢延迟。嗯,这是设计上的取舍,没办法。
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 上,需要嵌套使用。
12.6 手势冲突与嵌套
手势冲突是实际开发中最头疼的问题。比如一个列表项可以点击,列表本身可以滑动,两者怎么共存?
Compose 的解决方案是「手势优先级」。默认情况下,子组件的手势优先级高于父组件。但你可以通过 pointerInput 的 awaitPointerEventScope 手动控制事件消费。
@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 的事件分发机制。
12.7 知识体系总览
下面这张图帮你理清本章的核心脉络:
12.8 总结
手势处理是 Compose 交互的核心。从简单的 clickable,到灵活的 pointerInput,再到各种 detect*Gestures 封装,Compose 给了你足够的选择空间。
我的建议是:
- 能用
clickable就别自己造轮子 - 需要复杂手势时,优先用
detectTapGestures或detectDragGestures - 只有需要原始触摸数据时,才用
pointerInput - 多点触控用
detectTransformGestures,省心省力
记住一点:手势处理的核心是「事件消费」。谁消费了事件,谁就掌握了控制权。搞懂了这一点,大部分手势冲突问题都能迎刃而解。
公众号:蓝海资料掘金营,微信deep3321