手势与触摸:让应用“听懂”手指的语言
触摸交互,是移动应用最核心的体验。你想想看,用户跟你的 App 打交道,绝大多数时间就是手指在屏幕上点、滑、捏。如果手势处理不好,再炫酷的 UI 也是白搭。
我个人习惯把 Compose 的手势系统分成三层:基础点击、自定义手势、多点触控。今天我们就一层层剥开它。
核心观点:Compose 的手势 API 是声明式的,你不需要再写一堆 onTouchListener 和 MotionEvent 判断。但理解底层原理,能帮你写出更健壮的交互。
clickable:最常用的点击处理
先看最简单的。给一个 Composable 加点击事件,最直接的方式就是 clickable 修饰符。
@Composable
fun SimpleClickExample() {
var count by remember { mutableIntStateOf(0) }
Text(
text = "点击次数: $count",
modifier = Modifier
.clickable { count++ }
.padding(16.dp)
)
}
嗯,这里要注意:clickable 不只是加了个点击监听。它内部还处理了涟漪效果(Ripple)、无障碍语义、焦点请求。我在项目中遇到过一个问题——给一个 Row 加了 clickable,结果里面的 Button 点击没反应了。后来发现是 clickable 默认拦截了事件,需要用 clickable(enabled = false) 或者调整事件分发顺序。
小技巧:如果你只需要涟漪效果,不需要点击逻辑,可以用 indication 修饰符单独控制。
pointerInput:手势的“万能钥匙”
clickable 虽然方便,但遇到复杂手势就力不从心了。这时候就要请出 pointerInput。它直接暴露了底层的指针事件流,你可以用它实现任何自定义手势。
@Composable
fun PointerInputExample() {
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
// 处理原始触摸事件
when (event.type) {
PointerEventType.Move -> {
offset = event.changes.first().position
}
}
}
}
}
) {
Text("触摸位置: (${offset.x}, ${offset.y})")
}
}
说实话,直接写 awaitPointerEventScope 有点啰嗦。所以 Compose 提供了一系列 detect* 函数,帮你封装了常见手势模式。
detectTapGestures:点按、双击、长按
这个函数把各种“点击类”手势打包好了。我经常用它来实现双击点赞、长按删除这类交互。
@Composable
fun TapGestureExample() {
var status by remember { mutableStateOf("等待手势") }
Text(
text = status,
modifier = Modifier
.size(200.dp)
.background(Color.Magenta.copy(alpha = 0.3f))
.pointerInput(Unit) {
detectTapGestures(
onTap = { status = "单击" },
onDoubleTap = { status = "双击" },
onLongPress = { status = "长按" },
onPress = { status = "按下" }
)
},
textAlign = TextAlign.Center
)
}
避坑指南:我曾经在列表项里同时用了 clickable 和 detectTapGestures,结果双击永远触发不了。原因是 clickable 内部也消费了点击事件。记住:同一个 Composable 上不要混用 clickable 和 pointerInput 的点击检测。
detectDragGestures:拖拽手势
拖拽是滑动、侧滑删除、拖拽排序的基础。看一个最简单的拖拽方块例子:
@Composable
fun DragGestureExample() {
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.toInt(), offsetY.toInt()) }
.size(80.dp)
.background(Color.Blue)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume() // 消费事件,防止冲突
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
注意那个 change.consume()。我刚开始学的时候经常漏掉这行,结果拖拽时父容器也跟着滑动。说白了,消费事件就是告诉系统:“这个触摸点我接管了,你别再往上传递”。
detectDragGestures 还有几个变体:
detectVerticalDragGestures— 只检测垂直方向拖拽detectHorizontalDragGestures— 只检测水平方向拖拽detectDragGesturesAfterLongPress— 长按后拖拽(常用于拖拽排序)
缩放与旋转手势:双指操作
双指缩放和旋转,是图片查看器、地图应用的标配。Compose 用 detectTransformGestures 统一处理了这两种手势。
@Composable
fun TransformGestureExample() {
var scale by remember { mutableFloatStateOf(1f) }
var rotation by remember { mutableFloatStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.size(200.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
rotationZ = rotation
translationX = offset.x
translationY = offset.y
}
.background(Color.Green.copy(alpha = 0.3f))
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, rotationDelta ->
scale = (scale * zoom).coerceIn(0.5f, 3f)
rotation += rotationDelta
offset += pan
}
}
) {
Text(
"缩放: ${"%.2f".format(scale)}\n旋转: ${"%.0f".format(rotation)}°",
modifier = Modifier.align(Alignment.Center)
)
}
}
这里有个细节:detectTransformGestures 返回的 zoom 是缩放比例因子。比如用户双指捏合,zoom 可能是 0.8,表示缩小到原来的 80%。而 rotationDelta 是角度增量,单位是度。
经验之谈:我在做一个图片编辑器时,发现双指缩放和单指拖拽同时存在会互相干扰。解决方案是:在 detectTransformGestures 里判断 changes.size,只有双指时才处理缩放旋转,单指时只处理拖拽。
知识体系总览
下面这张图总结了 Compose 手势系统的核心脉络:
事件消费与冲突处理
手势处理中最头疼的问题就是事件冲突。比如一个可拖拽的卡片放在可滚动的列表中,你拖拽卡片时列表也在滚动。
解决方案有两个方向:
- 消费事件:在
change.consume()后,父容器不会再收到该事件。 - 嵌套滚动:使用
nestedScroll修饰符,让子容器和父容器协商事件分配。
// 嵌套滚动示例:子容器优先消费,剩余部分交给父容器
@Composable
fun NestedScrollExample() {
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 子容器先处理
return Offset(0f, available.y.coerceIn(-50f, 50f))
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
items(50) { index ->
Text("Item $index", modifier = Modifier.padding(16.dp))
}
}
}
我的习惯:遇到手势冲突,先别急着写复杂逻辑。试试调整 pointerInput 的 key 参数——有时候只是重组导致手势状态丢失了。
性能注意事项
手势处理如果写得不好,很容易造成卡顿。我踩过几个坑:
- 不要在 pointerInput 里做耗时操作——手势回调运行在 Main 线程,任何阻塞都会导致丢帧。
- 避免在拖拽回调里频繁重组——用
graphicsLayer代替offset修饰符,前者只触发绘制,不触发布局。 - 合理使用 remember——手势状态变量一定要用 remember 缓存,否则每次重组都会重置。
// 性能优化示例:使用 graphicsLayer 代替 offset
@Composable
fun OptimizedDrag() {
var offsetX by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.size(80.dp)
.graphicsLayer { translationX = offsetX } // 只触发绘制
.background(Color.Red)
.pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
offsetX += dragAmount
}
}
)
}
好了,手势与触摸这块的核心内容就这些。从 clickable 的便捷,到 pointerInput 的灵活,再到 detectTransformGestures 的强大,Compose 给了我们一套完整的手势工具箱。你在实际项目中遇到最多的手势场景是什么?不妨从今天讲的这几个 API 开始,试着封装成自己的手势工具库。