15、手势处理:clickable、draggable、swipeable、pointerInput 修饰符

手势处理,说白了就是让 App 听懂用户的「肢体语言」。你想想看,用户点一下、划一下、拖一下,背后都是事件流在驱动。Compose 在这方面做得挺优雅——它把手势抽象成修饰符,你直接往组件上一挂就行。

我个人习惯把 Compose 的手势修饰符分成两类:一类是「开箱即用」的,比如 clickabledraggableswipeable;另一类是「底层定制」的,也就是 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:滑动切换的利器

swipeabledraggable 高级一点。它定义了一组「锚点」,手指滑动到某个阈值后,组件会自动吸附到最近的锚点。典型的应用场景就是抽屉菜单、滑动删除、开关按钮。

@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) 指定绝对像素值。

核心思路:swipeable 的本质是「状态机 + 物理动画」。你定义好锚点和阈值,剩下的滑动、回弹、吸附动画,Compose 全帮你搞定。

15.4 pointerInput:终极定制方案

前面三个修饰符都是封装好的高级 API。如果你需要更精细的控制——比如区分单击和双击、识别长按、处理多点触控——那就得用 pointerInput 了。

pointerInput 接收一个协程作用域,里面可以调用 awaitPointerEventdetectTapGesturesdetectDragGestures 等底层函数。说白了,它就是手势处理的「乐高积木」。

@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 })
        // 手指抬起
    }
}

这种写法虽然啰嗦,但控制力最强。我在做一个自定义绘图板的时候就用到了它——需要精确追踪每一根手指的轨迹。

知识体系总览

下面这张图帮你理清四个修饰符的关系和适用场景:

Compose 手势修饰符体系 高级封装修饰符 底层定制修饰符 clickable 点击 + 水波纹 draggable 单指拖拽 swipeable 锚点滑动 pointerInput 原始事件 + 自定义手势 如何选择? • 只需要点击 → clickable • 需要自由拖拽 → draggable • 需要滑动切换状态 → swipeable • 需要自定义手势 → pointerInput • 需要多点触控 → pointerInput • 需要手势组合 → pointerInput

实战建议

最后给几个我自己的经验:

  • 能用高级 API 就别碰底层。 clickabledraggable 已经处理好了事件冲突、无障碍、动画曲线,自己写很容易漏掉边界情况。
  • 手势冲突是常态。 比如一个列表项既要支持点击又要支持滑动删除。我的做法是:用 swipeable 做滑动,然后在 onSwipe 回调里判断是否触发点击。如果滑动了超过 10px,就认为不是点击。
  • 别忘了 consume()pointerInput 里处理完事件后,记得调用 change.consume(),否则事件会继续往下传,导致奇怪的双触发问题。

手势处理这块,说白了就是「把用户的物理操作映射成业务逻辑」。Compose 给了你从简单到复杂的全套工具,关键是要选对合适的层级。

一句话总结:clickable 解决「点一下」,draggable 解决「拖一下」,swipeable 解决「滑一下」,pointerInput 解决「一切皆有可能」。


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