前言
在安卓开发中,触摸事件是用户与界面交互的基础。无论是点击按钮、滑动列表还是缩放图片,背后都离不开对触摸事件的处理。
在Jetpack Compose中,触摸事件主要通过 PointerInputModifierNode
实现。"Pointer" 是指针的意思,可以是用户手指、触控笔、鼠标指针(Android 支持鼠标操作);"Input" 则是输入的意思,所以 "PointerInput" 指的就是输入事件,但一般讲的都是触摸事件。
PointerInputModifierNode
的作用:让我们能够定制触摸反馈算法,以此来实现各种简单或复杂手势的识别与交互。
基本手势处理
Modifier.clickable():处理简单点击
对于最常见、最简单的点击事件,只需使用 Modifier.clickable()
修饰符,这就可以了。
kotlin
Box(
Modifier
.clickable {
// 处理点击逻辑
println("按钮被点击了!")
}
.background(Green)
.size(48.dp)
)
Modifier.combinedClickable():支持长按和双击
如果除了单击的需求外,还要监听长按、双击事件,可以使用 Modifier.combinedClickable()
。
kotlin
Modifier.combinedClickable(
onLongClick = { /* 长按处理 */ },
onDoubleClick = { /* 双击处理 */ },
onClick = { /* 单击处理 */ }
)
比如我可以这么写:
kotlin
Box(
Modifier
.combinedClickable(
onLongClick = {
Log.d("combinedClickable", "长按")
},
onDoubleClick = {
Log.d("combinedClickable", "双击")
},
onClick = {
Log.d("combinedClickable", "点击")
}
)
.background(Green)
.size(48.dp)
)
combinedClickable()
和 clickable()
是同一个层级的API,只是 combinedClickable()
多了一些功能。
进阶手势处理
Modifier.pointerInput() 与 detectTapGestures()
而如果你有更复杂的监听需求,就要使用Modifier.pointerInput()
结合 detectTapGestures()
函数来监听手势。
kotlin
Box(
Modifier
.pointerInput(Unit) { // 如果pointerInput的参数key的值不变,内部的点击事件逻辑也不会改变
detectTapGestures(
onPress = { offset ->
println("按压事件,位置:$offset")
},
onLongPress = { offset ->
println("长按事件,位置:$offset")
},
onDoubleTap = { offset ->
println("双击事件,位置:$offset")
},
onTap = { offset ->
println("点击事件,位置:$offset")
}
)
}
.background(Color.Blue)
.size(48.dp)
)
combinedClickable() 和 pointerInput() 的区别
到这你可能会有疑问,combinedClickable()
和 pointerInput()
能实现同样的功能(点击、双击、长按),为什么要有两种写法?
因为抽象层级不同,Modifier.pointerInput()
是一个更底层的函数,Modifier.combinedClickable()
的内部就是通过 Modifier.pointerInput()
来进行侦测的,combinedClickable()
使用更简单,但灵活性较低。
还有就是参数名的不同,一个叫click,另一个叫tap,tap强调的是物理上的轻触行为;而click强调系统功能上的点击。比如鼠标的点击会触发onClick,但不会触发onTap。
另外Modifier.pointerInput()
还对手指解除屏幕的事件有监听,使用的是onPress
参数,并且能获取触摸位置等详细信息。
总之一般情况下,使用 combinedClickable()
就足够了。只有在需要触摸位置或更底层的控制时,才选择 pointerInput()
。
自定义手势
awaitPointerEventScope():监听原始触摸事件
虽然 detectTapGestures()
比较底层,相对于Modifier.clickable()
/Modifier.combinedClickable()
这些高级API提供了更多控制和位置信息,但还不是最底层的,当我们想要去完全自定义手势识别逻辑时(比如定义自己的双击时间阈值),就要用到awaitPointerEventScope()
。
可以使用 awaitPointerEventScope()
来监听每一个触摸事件。
scss
Modifier
.pointerInput(Unit){
awaitPointerEventScope {
val down = awaitFirstDown() // 等待首个按下事件
val up = waitForUpOrCancellation() // 等待抬起或者取消事件
if (up != null) {
println("完成了一次点击")
}
}
}
awaitEachGesture():持续监听手势
另外我们常常会使用 awaitEachGesture()
来替换 awaitPointerEventScope()
,awaitPointerEventScope()
只会监听一次完整的触摸过程(从按下到抬起),为了持续监听多次手势,就要使用awaitEachGesture()
。
kotlin
Box(
Modifier
.pointerInput(Unit) {
awaitEachGesture {
// 处理手势...
}
}
.background(Green)
.size(48.dp)
)
事实上,detectTapGestures()
内部就是这样写的:
kotlin
suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
) = coroutineScope {
val pressScope = PressGestureScopeImpl(this@detectTapGestures)
awaitEachGesture { ⚡
// ..
}
}
这说明 detectTapGestures()
只是对底层触摸事件的封装,方便我们直接使用定义好的手势逻辑。
底层实现原理
触摸事件的处理与 Compose 的布局过程紧密相关。
- 存储结构 :
PointerInputModifierNode
与DrawModifierNode
类似,通常归属于右侧最近的LayoutModifierNode
或组件内容。它的作用范围由右侧的布局节点决定的。 - 修饰符顺序 :当有多个
PointerInputModifierNode
时,遵循"左边包含右边"的关系。也就是说,左侧修饰符的触摸范围包含右侧修饰符,右侧的修饰符会先接收到触摸事件。
示例:
kotlin
@Composable
private fun PointInputDemo() {
Box(
modifier = Modifier
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
println("最外层拦截,位置:${down.position}")
}
}
.background(Color.Gray)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
println("点击了游戏背景,位置:${down.position}")
// 没消费事件,会传递到外层
}
}
.size(200.dp)
.background(Color.Red)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
println("点击了暂停按钮,位置:${down.position}")
down.consume() // 消费掉传进来的事件,阻止事件传递到外层
}
}
.requiredSize(50.dp)
)
}
}
代码解析
在这个例子中,外层Box尺寸为200dp,背景为灰色,表示游戏背景;内层Box尺寸为50dp,背景为红色,表示暂停按钮。
三个 pointerInput()
(从左到右)的触摸范围分别是 200dp、200dp、50dp,怎么样?明白吗?
最内层的pointerInput()
通过 down.consume()
消费了触摸事件,阻止事件传递到外层,这意味着点击暂停按钮后,只有它的逻辑会触发------打印"点击了暂停按钮,位置...",更外层触摸事件的逻辑不会触发。如果没有消费触摸事件,会将事件传递到外层。
运行结果
- 如果你点击红色暂停按钮区域,会看到日志输出: 点击了暂停按钮,位置:Offset(x, y)
- 如果你点击灰色背景区域,会看到日志输出: 点击了游戏背景,位置:Offset(x, y) 最外层拦截,位置:Offset(x, y)
总结
对于简单的点击处理(点击、长按、双击),优先使用clickable()
或combinedClickable()
;需要获取点击的位置信息时,使用pointerInput()
结合 detectTapGestures()
;只有在需要完全自定义手势逻辑时,才使用awaitPointerEventScope()
结合 awaitEachGesture()
。
原理:PointerInputModifierNode
的存储结构与 DrawModifierNode
一致,每个触摸修饰符的作用范围由右侧最近的 LayoutModifierNode
决定。多个触摸修饰符之间,左侧是右侧的"父级",具有更大的作用范围。