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。我在项目中遇到过设计师要求「点击无反馈」的场景,嗯,这时候就得关掉它。
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 直接帮你把单击、双击、长按都拆开了。我个人习惯把复杂手势逻辑封装成独立的扩展函数,这样代码更干净。
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 来应用缩放,性能比重组更好。你想想看,如果每次缩放都触发重组,那画面得多卡?
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))
}
}
}
}
onPostScroll 返回的是你消费了多少,不是还剩多少。
15.7 知识体系总览
下面这张图把本章的手势体系串起来了,方便你对照着理解:
15.8 手势冲突的通用解决思路
手势冲突说白了就是「两个组件都想吃同一个事件」。解决思路就三条:
- 明确消费链:子组件优先消费,消费不完的交给父组件。用
consume()控制。 - 方向分离:横向滑动和纵向滑动不会冲突,只有同方向才需要处理。
- 延迟判断:有些手势需要等用户移动一段距离后才能判断意图(比如是点击还是滑动)。Compose 的
awaitPointerEvent可以做到。
好了,手势这块就聊到这儿。记住一个原则:能用 clickable 就别用 pointerInput,能用现成的就别自己造。但当你需要精细控制时,pointerInput 和 NestedScrollConnection 就是你的底气。
公众号:蓝海资料掘金营,微信deep3321