43、Compose手势与输入:clickable、pointerInput、TextField、手势检测

手势和输入,是移动端交互的灵魂。

我做了这么多年UI,发现很多开发者把精力都花在布局和动画上,却忽略了最基础的手势处理。结果呢?用户点一下没反应,滑一下卡顿,输入框弹起来又把界面挤得乱七八糟。嗯,这些坑我都踩过。

今天咱们就把Compose里的手势和输入彻底捋一遍。从最简单的点击,到自定义手势检测,再到TextField的输入处理,一步到位。

43.1 clickable:最常用的点击处理

在Compose里,给一个组件加点击事件,最直接的方式就是clickable修饰符。它比传统的setOnClickListener要灵活得多。

@Composable
fun SimpleClickExample() {
    var count by remember { mutableStateOf(0) }
    
    Text(
        text = "点击次数: $count",
        modifier = Modifier
            .clickable { count++ }
            .padding(16.dp)
    )
}

你看,就这么简单。但clickable其实做了很多事:它处理了按下、抬起、取消的状态,还自带涟漪效果。我个人习惯在需要点击反馈的地方优先用clickable,而不是自己去拼pointerInput

小技巧: 如果你不想要涟漪效果,可以用 clickable(interactionSource = null, indication = null) 来禁用。

不过要注意,clickable只能处理点击。如果你想区分长按、双击,或者自定义手势,那就得请出pointerInput了。

43.2 pointerInput:自定义手势的瑞士军刀

pointerInput是Compose手势处理的底层API。它直接操作指针事件流,你可以用它检测任何手势。

为什么会需要它?举个例子。我在项目中做过一个画板应用,用户需要用手指画线。用clickable显然不行,必须用pointerInput来追踪手指的移动轨迹。

@Composable
fun DrawBoard() {
    var points by remember { mutableStateOf(listOf<Offset>()) }
    
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consume()
                    points = points + change.position
                }
            }
    ) {
        // 绘制路径
        val path = Path().apply {
            if (points.isNotEmpty()) {
                moveTo(points[0].x, points[0].y)
                for (i in 1 until points.size) {
                    lineTo(points[i].x, points[i].y)
                }
            }
        }
        drawPath(path, color = Color.Black, style = Stroke(width = 4f))
    }
}

pointerInput里可以嵌套多个手势检测器。比如同时检测点击和拖拽:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val down = awaitFirstDown()
        // 等待一段时间,判断是点击还是拖拽
        val up = withTimeoutOrNull(300L) {
            waitForUpOrCancellation()
        }
        if (up != null) {
            // 点击事件
        } else {
            // 拖拽事件
        }
    }
}
注意: pointerInput 的 key 参数很重要。如果 key 不变,协程不会重启;如果 key 变了,会取消旧协程并启动新协程。我曾经因为 key 没设对,导致手势检测失效,排查了半天。

43.3 手势检测:从原始事件到高级手势

Compose内置了几种常用的手势检测器,封装在androidx.compose.foundation.gestures包里。我列个表,方便你对照:

手势检测器 功能 适用场景
detectTapGestures 检测单击、双击、长按 按钮、列表项、图片查看
detectDragGestures 检测拖拽 滑动条、画板、拖拽排序
detectTransformGestures 检测缩放、旋转、平移 图片缩放、地图操作
detectVerticalDragGestures 检测垂直拖拽 下拉刷新、滚动列表

举个例子,实现一个支持双击缩放和拖拽的图片查看器:

@Composable
fun ZoomableImage() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    
    Image(
        painter = painterResource(id = R.drawable.sample),
        contentDescription = null,
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { centroid, pan, zoom, rotation ->
                    scale = (scale * zoom).coerceIn(0.5f, 3f)
                    offset = offset + pan
                }
            }
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                translationX = offset.x
                translationY = offset.y
            }
    )
}

你想想看,如果用传统View系统实现同样的功能,得写多少模板代码?Compose把底层细节都封装好了,我们只需要关注业务逻辑。

43.4 TextField:输入框的正确打开方式

TextField是Compose里最复杂的组件之一。它涉及输入、焦点、键盘、验证等多个方面。我见过太多人把TextField用错了。

先看一个基础用法:

@Composable
fun BasicTextFieldExample() {
    var text by remember { mutableStateOf("") }
    
    TextField(
        value = text,
        onValueChange = { text = it },
        label = { Text("请输入内容") },
        modifier = Modifier.fillMaxWidth()
    )
}

这里有个关键点:valueonValueChange必须成对出现。Compose是单向数据流,你不能只传value不传onValueChange,否则输入框会变成只读。

核心原则: TextField 的状态必须由外部控制。永远不要用 remember 在 TextField 内部管理状态,除非你确定不需要外部读取。

实际项目中,我们经常需要对输入做验证。比如限制输入长度、过滤特殊字符:

@Composable
fun ValidatedTextField() {
    var text by remember { mutableStateOf("") }
    var error by remember { mutableStateOf<String?>(null) }
    
    TextField(
        value = text,
        onValueChange = { newValue ->
            // 只允许输入数字
            val filtered = newValue.filter { it.isDigit() }
            if (filtered.length <= 11) {
                text = filtered
                error = null
            } else {
                error = "最多输入11位数字"
            }
        },
        label = { Text("手机号") },
        isError = error != null,
        supportingText = { error?.let { Text(it, color = MaterialTheme.colorScheme.error) } },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        modifier = Modifier.fillMaxWidth()
    )
}

嗯,这里要注意keyboardOptions。设置KeyboardType.Number后,系统会自动弹出数字键盘。但如果你在onValueChange里过滤了非数字字符,用户按字母键也不会生效,体验会有点奇怪。我建议要么用KeyboardType.Number限制键盘类型,要么在onValueChange里做过滤,两者选其一就好。

43.5 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图:

Compose 手势与输入知识体系 手势与输入 clickable 单击、涟漪效果 禁用指示器:indication = null pointerInput 原始指针事件流 awaitEachGesture 协程 手势检测器 detectTapGestures detectDragGestures detectTransformGestures TextField 状态管理、输入验证、键盘类型

这张图把本章的核心内容串起来了。你可以看到,clickable适合简单场景,pointerInput适合自定义手势,手势检测器是封装好的常用模式,而TextField则是输入处理的集大成者。

43.6 避坑指南

最后,分享几个我踩过的坑:

  • 手势冲突: 如果一个组件同时有clickablepointerInput,后者会优先消费事件。我曾经在列表项上同时用了点击和拖拽,结果拖拽时总是触发点击。解决方案是在pointerInput里用change.consume()明确消费事件。
  • TextField的焦点管理: 默认情况下,点击TextField会自动获取焦点。但如果你在弹窗或底部菜单里用TextField,焦点可能会被抢走。用FocusRequester可以手动控制焦点。
  • 性能问题: pointerInput里的协程如果处理不当,会导致卡顿。比如在拖拽事件里做大量计算,建议把计算逻辑放到withFrameMilliswithFrameNanos里,跟帧率同步。

好了,手势和输入这块就讲到这里。记住一个原则:能用clickable就别用pointerInput,能用封装好的手势检测器就别自己写。代码越简单,bug越少。

我的习惯: 每次写手势处理前,先问自己三个问题——这个手势是系统自带的吗?用户期望的交互是什么?有没有现成的API可以用?想清楚再动手,能省一半时间。

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