15、手势处理:clickable、draggable、swipeable、pointerInput 修饰符
手势处理,说白了就是让 App 听懂用户的「肢体语言」。你想想看,用户点一下、划一下、拖一下,背后都是事件流在驱动。Compose 在这方面做得挺优雅——它把手势抽象成修饰符,你直接往组件上一挂就行。
我个人习惯把 Compose 的手势修饰符分成两类:一类是「开箱即用」的,比如 clickable、draggable、swipeable;另一类是「底层定制」的,也就是 pointerInput。今天咱们就把这四个东西彻底聊透。
15.1 clickable:最基础的点击处理
clickable 应该是大家用得最多的修饰符了。它不光处理点击,还自带水波纹效果和无障碍支持。我在项目中遇到过有人自己写 Modifier.pointerInput 去模拟点击,结果无障碍测试直接挂掉——何必呢?
@Composable
fun ClickableDemo() {
var count by remember { mutableIntStateOf(0) }
Text(
text = "点击次数: $count",
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple()
) {
count++
}
.padding(16.dp)
)
}
indication 设为 null。但我不建议这么做——用户习惯了视觉反馈,突然没波纹会觉得很奇怪。
clickable 还有一个隐藏参数 enabled,默认是 true。设为 false 后,点击事件不会触发,而且组件会自动变灰。嗯,这里要注意:变灰的样式是系统默认的,如果你想要自定义禁用态样式,得自己加 alpha 或者改颜色。
15.2 draggable:让组件跟着手指走
拖拽手势在 Compose 里用 draggable 实现。它分两个方向:水平拖拽和垂直拖拽。我记得第一次用的时候踩了个坑——draggable 只返回偏移量,不帮你移动组件。你得自己把偏移量存到状态里,然后手动设置 offset。
@Composable
fun DraggableDemo() {
var offsetX by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.size(80.dp)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offsetX += delta
}
)
.background(Color.Blue)
)
}
rememberDraggableState 里的 lambda 接收的是 增量,不是绝对位置。如果你直接赋值 offsetX = delta,那组件会瞬间跳回原点再拖。正确做法是累加:offsetX += delta。
另外,draggable 只处理单指拖拽。如果你需要双指缩放、旋转之类的,得用 transformable 或者自己写 pointerInput。
15.3 swipeable:滑动切换的利器
swipeable 比 draggable 高级一点。它定义了一组「锚点」,手指滑动到某个阈值后,组件会自动吸附到最近的锚点。典型的应用场景就是抽屉菜单、滑动删除、开关按钮。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableDemo() {
val swipeableState = rememberSwipeableState(initialValue = 0)
val anchors = mapOf(
0f to 0, // 起始位置
300f to 1 // 滑到 300px 后切换到状态 1
)
Box(
modifier = Modifier
.width(400.dp)
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Text(
text = "当前状态: ${swipeableState.currentValue}",
modifier = Modifier.padding(16.dp)
)
}
}
这里有个关键点:thresholds 参数控制「滑到多少比例就算切换」。我习惯用 FractionalThreshold(0.3f),意思是滑过 30% 就自动切过去。你也可以用 FixedThreshold(dp) 指定绝对像素值。
15.4 pointerInput:终极定制方案
前面三个修饰符都是封装好的高级 API。如果你需要更精细的控制——比如区分单击和双击、识别长按、处理多点触控——那就得用 pointerInput 了。
pointerInput 接收一个协程作用域,里面可以调用 awaitPointerEvent、detectTapGestures、detectDragGestures 等底层函数。说白了,它就是手势处理的「乐高积木」。
@Composable
fun PointerInputDemo() {
var text by remember { mutableStateOf("点击或拖动我") }
Box(
modifier = Modifier
.size(200.dp)
.background(Color.Cyan)
.pointerInput(Unit) {
detectTapGestures(
onTap = { text = "单击" },
onDoubleTap = { text = "双击" },
onLongPress = { text = "长按" }
)
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
text = "拖动: ${dragAmount.x.toInt()}, ${dragAmount.y.toInt()}"
}
}
) {
Text(text = text, modifier = Modifier.align(Alignment.Center))
}
}
pointerInput 修饰符会按顺序处理事件。如果你先写 detectTapGestures 再写 detectDragGestures,那拖动时可能会被点击拦截。我建议把拖动放在前面,或者用 awaitEachGesture 手动控制事件分发。
还有一个我常用的模式:用 awaitPointerEventScope 手动监听原始事件。比如你想实现「手指按下时变红,抬起时恢复」,可以这样写:
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
// 手指按下
// 更新状态...
do {
val event = awaitPointerEvent()
// 处理移动事件
} while (event.changes.any { it.pressed })
// 手指抬起
}
}
这种写法虽然啰嗦,但控制力最强。我在做一个自定义绘图板的时候就用到了它——需要精确追踪每一根手指的轨迹。
知识体系总览
下面这张图帮你理清四个修饰符的关系和适用场景:
实战建议
最后给几个我自己的经验:
- 能用高级 API 就别碰底层。
clickable和draggable已经处理好了事件冲突、无障碍、动画曲线,自己写很容易漏掉边界情况。 - 手势冲突是常态。 比如一个列表项既要支持点击又要支持滑动删除。我的做法是:用
swipeable做滑动,然后在onSwipe回调里判断是否触发点击。如果滑动了超过 10px,就认为不是点击。 - 别忘了
consume()。 在pointerInput里处理完事件后,记得调用change.consume(),否则事件会继续往下传,导致奇怪的双触发问题。
手势处理这块,说白了就是「把用户的物理操作映射成业务逻辑」。Compose 给了你从简单到复杂的全套工具,关键是要选对合适的层级。
一句话总结:clickable 解决「点一下」,draggable 解决「拖一下」,swipeable 解决「滑一下」,pointerInput 解决「一切皆有可能」。
公众号:蓝海资料掘金营,微信deep3321