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()
)
}
这里有个关键点:value和onValueChange必须成对出现。Compose是单向数据流,你不能只传value不传onValueChange,否则输入框会变成只读。
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 知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
这张图把本章的核心内容串起来了。你可以看到,clickable适合简单场景,pointerInput适合自定义手势,手势检测器是封装好的常用模式,而TextField则是输入处理的集大成者。
43.6 避坑指南
最后,分享几个我踩过的坑:
- 手势冲突: 如果一个组件同时有
clickable和pointerInput,后者会优先消费事件。我曾经在列表项上同时用了点击和拖拽,结果拖拽时总是触发点击。解决方案是在pointerInput里用change.consume()明确消费事件。 - TextField的焦点管理: 默认情况下,点击TextField会自动获取焦点。但如果你在弹窗或底部菜单里用TextField,焦点可能会被抢走。用
FocusRequester可以手动控制焦点。 - 性能问题:
pointerInput里的协程如果处理不当,会导致卡顿。比如在拖拽事件里做大量计算,建议把计算逻辑放到withFrameMillis或withFrameNanos里,跟帧率同步。
好了,手势和输入这块就讲到这里。记住一个原则:能用clickable就别用pointerInput,能用封装好的手势检测器就别自己写。代码越简单,bug越少。
公众号:蓝海资料掘金营,微信deep3321