15、手势与触摸:clickable、pointerInput、拖拽、缩放、滑动、嵌套滚动

手势交互,说白了就是用户和屏幕之间的「肢体语言」。你想想看,用户点一下、划一下、捏一下,App 就得给出反馈。在 Jetpack Compose 里,这套机制被封装得相当优雅,但坑也不少。今天我就带你把这些手势挨个捋一遍。

15.1 基础点击:clickable 修饰符

最常用的手势就是点击。Compose 里给任意组件加点击事件,直接用 clickable 修饰符就行。

@Composable
fun ClickableDemo() {
    Box(
        modifier = Modifier
            .size(120.dp)
            .background(Color.LightGray)
            .clickable { 
                // 处理点击
                println("Box clicked")
            }
    ) {
        Text("点我", modifier = Modifier.align(Alignment.Center))
    }
}

这里有个细节——clickable 默认会添加一个涟漪效果(Ripple)。如果你不想要,可以传 indication = null。我在项目中遇到过设计师要求「点击无反馈」的场景,嗯,这时候就得关掉它。

小提示: clickable 其实内部封装了 pointerInput 和 gestureDetector。如果你只需要简单点击,用它就够了,别自己造轮子。

15.2 底层手势:pointerInput 修饰符

当 clickable 满足不了你时——比如你想区分单击、双击、长按——就得用 pointerInput 了。它直接暴露了原始触摸事件流。

@Composable
fun PointerInputDemo() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Cyan)
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = { offset -> 
                        println("单击: $offset")
                    },
                    onDoubleTap = { offset ->
                        println("双击: $offset")
                    },
                    onLongPress = { offset ->
                        println("长按: $offset")
                    }
                )
            }
    )
}

你看,detectTapGestures 直接帮你把单击、双击、长按都拆开了。我个人习惯把复杂手势逻辑封装成独立的扩展函数,这样代码更干净。

注意: pointerInput 的 key 参数(上面传的 Unit)很关键。如果 key 变了,手势检测会重新启动。我曾经因为 key 传了个会变的状态,导致手势莫名其妙失效——排查了半天才发现。

15.3 拖拽:detectDragGestures

拖拽是移动端最基础的手势之一。Compose 里用 detectDragGestures 实现。

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

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
            .size(80.dp)
            .background(Color.Magenta)
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consume()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                }
            }
    )
}

注意 change.consume() 这行——它告诉系统「这个事件我处理了,别往下传」。不调用的话,事件可能会被其他手势拦截。我在项目中遇到过拖拽和点击冲突的问题,最后就是靠正确调用 consume() 解决的。

15.4 缩放:detectTransformGestures

双指缩放,在 Compose 里用 detectTransformGestures 搞定。它会同时返回缩放比例、平移量和旋转角度。

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

    Box(
        modifier = Modifier
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
            .size(150.dp)
            .background(Color.Green)
            .pointerInput(Unit) {
                detectTransformGestures { _, _, zoom, _ ->
                    scale = (scale * zoom).coerceIn(0.5f, 3f)
                }
            },
        contentAlignment = Alignment.Center
    ) {
        Text("捏合缩放")
    }
}

这里我用 graphicsLayer 来应用缩放,性能比重组更好。你想想看,如果每次缩放都触发重组,那画面得多卡?

核心要点: detectTransformGestures 返回的 zoom 是相对值(比如 1.1 表示放大了 10%),不是绝对值。所以要用乘法累加。

15.5 滑动:LazyColumn 与 scrollable

滑动分两种:一种是列表滑动(LazyColumn、LazyRow),另一种是自定义区域的滑动。

列表滑动直接用 LazyColumn 就行,它内置了滑动手势和回收机制。

@Composable
fun LazyScrollDemo() {
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(100) { index ->
            Text(
                text = "第 $index 项",
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            )
        }
    }
}

自定义滑动scrollable 修饰符,配合 ScrollState

@Composable
fun CustomScrollDemo() {
    val scrollState = rememberScrollState()

    Column(
        modifier = Modifier
            .verticalScroll(scrollState)
            .padding(16.dp)
    ) {
        repeat(50) {
            Text("可滚动内容 $it", modifier = Modifier.padding(8.dp))
        }
    }
}

我个人习惯用 rememberScrollState() 而不是自己创建 ScrollState,因为它已经帮你处理好了保存和恢复状态。

15.6 嵌套滚动:NestedScrollConnection

嵌套滚动是坑最多的地方。比如一个横向滑动的 ViewPager 里面套了一个纵向滑动的列表——两个方向不同还好,如果方向相同,就会抢事件。

Compose 提供了 NestedScrollConnection 来解决这个问题。

class MyNestedScrollConnection : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        // 在子组件消费之前拦截
        return Offset.Zero // 不拦截
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        // 子组件消费完后,处理剩余的部分
        return Offset.Zero
    }
}

使用时通过 nestedScroll 修饰符挂载:

@Composable
fun NestedScrollDemo() {
    val connection = remember { MyNestedScrollConnection() }

    Box(
        modifier = Modifier
            .nestedScroll(connection)
            .fillMaxSize()
    ) {
        LazyColumn {
            items(50) { index ->
                Text("嵌套滚动项 $index", modifier = Modifier.padding(16.dp))
            }
        }
    }
}
避坑指南: 我曾经在项目中遇到一个 Bug——列表滑到底部后,父容器开始滑动,但松手后列表会「弹回去」。后来发现是 onPostScroll 返回了错误的 consumed 值。记住:onPostScroll 返回的是你消费了多少,不是还剩多少。

15.7 知识体系总览

下面这张图把本章的手势体系串起来了,方便你对照着理解:

Compose 手势与触摸体系 手势修饰符 简单手势 clickable combinedClickable 原始手势 pointerInput detectTapGestures detectDragGestures detectTransformGestures 滚动与嵌套 scrollable / ScrollState LazyColumn / LazyRow NestedScrollConnection 三者关系:简单手势基于原始手势实现,滚动与嵌套解决手势冲突

15.8 手势冲突的通用解决思路

手势冲突说白了就是「两个组件都想吃同一个事件」。解决思路就三条:

  1. 明确消费链:子组件优先消费,消费不完的交给父组件。用 consume() 控制。
  2. 方向分离:横向滑动和纵向滑动不会冲突,只有同方向才需要处理。
  3. 延迟判断:有些手势需要等用户移动一段距离后才能判断意图(比如是点击还是滑动)。Compose 的 awaitPointerEvent 可以做到。
我的经验: 遇到手势冲突,先别急着写代码。拿张纸画出事件流向图——谁先收到、谁消费了多少、剩下的给谁。画清楚了,代码自然就写出来了。

好了,手势这块就聊到这儿。记住一个原则:能用 clickable 就别用 pointerInput,能用现成的就别自己造。但当你需要精细控制时,pointerInputNestedScrollConnection 就是你的底气。


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