多指手势识别:detectTransformGestures
功能与使用场景
对于常见的多指变换手势:平移(Pan)、缩放(Zoom)和旋转(Rotation),Compose 提供了一个便捷的函数 detectTransformGestures,它封装了对这些手势的底层识别逻辑。
你需要在 Modifier.pointerInput 的尾 lambda 表达式中去调用这个函数,准确来说是具有 PointerInputScope 环境的地方使用,像这样:
Modifier.pointerInput(key1 = Unit){
detectTransformGestures { centroid, pan, zoom, rotation ->
// centroid: 触摸点的中心点
// pan: 平移的偏移量
// zoom: 缩放的倍数变化
// rotation: 旋转的角度变化
}
}
要理解这四个参数,我们先来看看 detectTransformGestures 的使用场景。
detectTransformGestures 主要用于图片查看器中图片的缩放、平移和旋转,或者地图应用中类似的操作,比如下图中:
我们可以通过两根手指的捏撑来缩放地图、转动来旋转地图、平移来移动地图。
虽然平移操作单指手势也能完成,但
detectTransformGestures提供了对多指手势的支持,它会根据所有手指的中心位置来计算平移,而不是只跟踪一个手指。
参数详解
了解完使用场景后,我们来看这四个参数:
-
centroid:centroid 是中心点的意思,在计算机图形学中,通常指的是所有触摸点(手指)的几何中心位置。它一般是作为后面三个参数的辅助参数。 -
pan:表示本次事件回调相比上一次回调,所有触摸点的几何中心的位移变化量。 -
zoom:表示本次事件回调相比上一次回调,因手指间的平均距离变化导致的缩放比例的变化。注意:这不是相对于手势开始的初始状态的缩放比例,而是相对上一瞬间的。 -
rotation:表示本次事件回调相比上一次回调,因手指旋转产生的角度变化量。同样地,这也是相对于上一瞬间的。
我们来写一个示例,对一张图片进行平移、缩放和旋转:
@Composable
fun TransformableImage() {
var scale by remember { mutableFloatStateOf(1f) }
var rotation by remember { mutableFloatStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures{ centroid, pan, zoomChange, rotationChange ->
offset += pan
scale *= zoomChange
rotation += rotationChange
}
},
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.avatar),
contentDescription = null,
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
)
}
}
运行效果:
panZoomLock 参数
并且 detectTransformGestures 还有一个很有用的参数 panZoomLock。
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false, // 默认是 false
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)
panZoomLock 参数默认值是 false,此时平移、缩放和旋转手势都可以被同时识别,这意味着用户在进行捏合缩放图片时,如果手指不小心轻微移动或旋转,pan、zoom 和 rotation 参数都会变化。
如果参数值为 true,旋转和平移/缩放操作就互斥了。
-
如果用户的手势先被识别为了旋转(旋转角度变化量先达到定义的阈值),那么在所有手指抬起离开屏幕之前,所有的平移、放缩行为将不被识别,具体来说就是
pan、zoom参数的值是不变的,pan的值为Offset.Zero,zoom的值为1f。 -
反之,手势先被识别为了平移或放缩,那么在所有手指抬起离开屏幕前,将不再识别旋转手势,
rotation参数的值不变,为0f。
这样可以提高用户的体验感,防止用户在缩放或者移动一张图片时,因为手指微小的旋转,导致图片也跟着旋转,让用户感觉操作更可控、更精确。
典型的例子就是我们前面的示例,双指移动和缩放图片时,图片的抖动非常大。如果要改善操作的稳定性,只需将 detectTransformGestures 函数的 panZoomLock 参数值设为 true 就行了,像这样:
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures(panZoomLock = true) { centroid, pan, zoomChange, rotationChange ->
offset += pan
scale *= zoomChange
rotation += rotationChange
}
},
运行效果:
怎么样,是不是舒服多了?
100% 自定义触摸算法
之前我们都是使用预设的 API 来完成手势的识别,比如detectTapGestures、detectDragGestures、detectTransformGestures,现在我们来直接处理最底层的指针事件,来自定义手势的识别算法。
依旧使用的是 Modifier.pointerInput,因为它是处理所有指针事件的入口。
Modifier.pointerInput(key1 = Unit){
// 手势处理的协程代码
}
再次简单介绍一下 pointerInput:
key参数的值改变时,内部的协程会被取消并重启,可用于改变手势识别逻辑。- 尾 lambda 表达式是一个挂起函数(运行在协程中),我们可以在内部调用其他挂起函数,这非常适合以线性、看似“同步”的代码风格来处理异步的触摸事件流。
那怎么能够获取原始的指针事件呢?
只需调用 awaitPointerEvent 函数就能获取一个原始的 PointerEvent 对象。但它需要AwaitPointerEventScope 的上下文,我们可以通过调用 awaitPointerEventScope 函数来提供。
AwaitPointerEventScope 上下文提供了一些有用的属性,例如 size 属性是当前组件的尺寸。
让我们来看一个简单的例子:
Text("我是文本呀,你可以摸一下我", Modifier.pointerInput(Unit) {
awaitPointerEventScope {
// 获取一个指针事件,其中包含了该时刻所有触摸点(手指)的信息
val event: PointerEvent = awaitPointerEvent() // 挂起直到下一个事件发生
// 事件的类型有 Press、Release、Move、Enter、Exit、Scroll
println("获取到了一个指针事件,事件类型:${event.type}")
}
})
运行后,你点击文字,控制台会打印:
D/System.out 获取到了一个指针事件,事件类型:Press
这样虽然获取到了按压事件,但你很快就会发现不对劲了:为什么手指的抬起事件没有被获取到?并且当你再次点击文字时,你会发现控制台并没有再次打印 “获取到了一个指针事件,事件类型:Press”。
这是因为 awaitPointerEvent() 只会获取一个事件,而我们在这个协程中,也只做了一次事件的获取。一旦获取到了这个事件,awaitPointerEventScope { ... } 代码块中的代码就执行完毕了,协程也会被取消。
为了持续监听事件,我们需要将 awaitPointerEvent() 放入一个 while 循环中。
修改后的代码:
Text("请随意触摸我", Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
// 循环获取事件
while (true) {
val event = awaitPointerEvent()
println("监测到了一个指针事件,事件类型:${event.type}")
}
}
})
运行结果:
D/System.out 监测到了一个指针事件,事件类型:Press
D/System.out 监测到了一个指针事件,事件类型:Move
D/System.out 监测到了一个指针事件,事件类型:Release
D/System.out 监测到了一个指针事件,事件类型:Press
...
现在我们可以持续地获取事件了。
点击手势的识别逻辑
现在我们来完成自定义的点击手势的监听。
我们先简单认为:只要获取到了一个抬起类型的事件,就算完成了一次点击。我们将逻辑抽取到 Composable 函数中:
@Composable
fun Modifier.myClickable(onClick: () -> Unit) =
pointerInput(key1 = Unit) {
awaitPointerEventScope {
// 循环获取指针事件
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Release) {
onClick()
}
}
}
}
测试一下:
Text("点我", Modifier.myClickable {
println("你点击了文字")
})
运行结果:
D/System.out 你点击了文字
D/System.out 你点击了文字
D/System.out 你点击了文字
...
在以上的基础上,添加一个要求:如果手指按下后,在抬起前移出了组件的范围内,就不算一次点击。哪怕手指抬起时,触摸点在组件的范围内,也不算一次有效的点击。
完成只需两步:
- 监听初始的按下事件
- 然后在手指抬起前,持续进行监听事件:如果是移动事件,就看看有没有出组件的范围,出了就结束监听,取消当前手势;如果是抬起事件,就完成一次有效的点击,也结束监听。
@Composable
fun Modifier.myClickable(onClick: () -> Unit) =
pointerInput(key1 = Unit) {
awaitPointerEventScope {
// 获取按下事件
var event = awaitPointerEvent()
while (true) {
event = awaitPointerEvent()
if (event.type == PointerEventType.Move) {
if (event.changes.first().isOutOfBounds(size, extendedTouchPadding)){
println("按下后,移动过程超出了组件范围")
break
}
} else if (event.type == PointerEventType.Release) {
onClick()
break
}
}
}
}
上述获取的 event.changes 是一个 List<PointerInputChange> 对象,因为安卓系统支持多点触摸,所以这个对象表示的是从上一次事件到当前事件,所有发生变化的触摸点(手指)的信息,每个 PointerInputChange 对象表示一个触摸点。因为在这里的演示中,不包括多指的触摸,所以我们只获取第一个触摸点的信息。
判断是否超出边界,你可以获取到触摸点,再获取里面的 position 位置,与当前组件的尺寸进行比较,像这样:
val position = event.changes.first().position
if (position.x < 0 || position.y < 0 || position.x > size.width || position.y > size.height) {
println("按下后,移动过程超出了组件范围")
break
}
不过示例中是调用了 PointerInputChange 提供的 isOutOfBounds 方法来判断。
到这里,你觉得行了吗?
还是和最开始一样的问题:当 while(...) 代码块结束后,准确来说是 awaitPointerEventScope{...} 代码块中的代码执行完后,这个协程也就结束了,就不能再次监听指针事件了。
所以我们需要在最外面再套上一个 while 循环:
@Composable
fun Modifier.myClickable(onClick: () -> Unit) =
pointerInput(key1 = Unit) {
awaitPointerEventScope {
while (true) {
// 获取按下事件
var event = awaitPointerEvent()
println("获取到按下类型的事件")
while (true) {
event = awaitPointerEvent()
if (event.type == PointerEventType.Move) {
println("获取到移动类型的事件")
if (event.changes.first().isOutOfBounds(size, extendedTouchPadding)) {
println("按下后,移动过程超出了组件范围")
break
}
} else if (event.type == PointerEventType.Release) {
println("按下后,获取到抬起类型的事件,完成一次点击")
onClick()
break
} else {
continue
}
}
}
}
}
实际开发中也是这样做的,但更多是使用 Compose 提供的 awaitEachGesture 函数,用它来代替 awaitPointerEventScope { while (true) { ... } }。
使用 awaitEachGesture 函数替换后的代码:
@Composable
fun Modifier.myClickable(onClick: () -> Unit) =
pointerInput(key1 = Unit) {
awaitEachGesture {
var event = awaitPointerEvent()
println("获取到按下类型的事件")
while (true) {
event = awaitPointerEvent()
if (event.type == PointerEventType.Move) {
println("获取到移动类型的事件")
if (event.changes.first().isOutOfBounds(size, extendedTouchPadding)) {
println("按下后,移动过程超出了组件范围")
break
}
} else if (event.type == PointerEventType.Release) {
println("按下后,获取到抬起类型的事件,完成一次点击")
onClick()
break
} else {
continue
}
}
}
}
对于上述的按下和抬起事件之间的检查,其实是有些不周到,甚至是错误的。
因为安卓是支持多点触摸的,所以任何一个手指的抬起,事件类型都是 PointerEventType.Release,因此在上述逻辑中,每根手指的抬起,都会完成一次有效点击。
所以我们要判断是最后一个手指的抬起,再调用点击回调,逻辑才正确:
else if (event.type == PointerEventType.Release && event.changes.size == 1) {
println("按下后,获取到抬起类型的事件,完成一次点击")
onClick()
break
}
每一个手指抬起后的瞬间,都会从
changes中被移除。
我们还可以使用别的方法来完成这种判断,waitForUpOrCancellation 在所有手指都抬起后,会返回最后一个抬起的手指的信息;如果手势被取消,比如出了组件边界,会返回 null。
同理,按下时,可能会多根手指的按下,你需要判断第一根手指的话,可以使用 awaitFirstDown 函数。
引入这两个函数,然后再次修改我们的代码:
@Composable
fun Modifier.myClickable(onClick: () -> Unit): Modifier =
pointerInput(Unit) {
awaitEachGesture {
// 等待第一个手指按下
val down: PointerInputChange = awaitFirstDown(requireUnconsumed = true)
// 等待手指抬起,或手势被取消
val up: PointerInputChange? = waitForUpOrCancellation()
if (up != null) {
// 手指正常抬起,并且手势未被取消
up.consume() // 消费抬起事件,表示我们已处理完成这次点击
println("有效点击")
onClick()
} else {
// 手势被取消
println("点击被取消")
}
}
}
事件的传递与消费机制
理解 Compose 中的指针事件传递和消费机制,对于解决手势冲突非常重要。因为它决定了哪个组件响应用户的触摸,以及多个组件如何协同处理同一个手势。
我们先来看看事件传递流程。
事件传递的三个阶段
当用户触摸屏幕产生了一个指针事件时,它在 UI 组件树的传递:
-
Initial 阶段 (父组件 -> 子组件):
事件首先被传递给最外层的父组件,然后逐级向下传递到实际触摸点所在的最内层的子组件。在这个传递过程中,每一级组件都可以获取并选择消费这个事件中的某些变化,也就是父组件可以优先处理事件。即使某个触摸点的变化被父组件消费了,事件依然会被向下传递给子组件,但这个事件会被标记上“已消费”,子组件可以看到哪些事件已被消费了。
-
Main 阶段 (子组件 -> 父组件):
当事件到了最内层的目标子组件后,它会从子组件开始逐级向上传递到父组件。
awaitPointerEvent()默认监听的就是这个阶段的事件。大多数手势逻辑(如识别点击、长按、拖动等)的实际响应和消费行为都发生在此阶段,如果某个子组件消费了某个触摸点的变化,父组件也能看到这个事件已被消费了的标记。 -
Final 阶段 (父组件 -> 子组件):
在
Main阶段结束后,事件会再次从父组件向子组件向下传递。这个阶段非常重要,用来处理手势冲突,特别是手势取消的逻辑。子组件会在这个阶段检查事件中触摸点的消费状态,如果它发现它所依赖的某个触摸点的变化在 Main 阶段被其某个父组件消费了,那么子组件会在当前阶段取消自己当前正在进行的手势识别。
为什么是这样的流程? 举几个例子:
比如下面这是一个文章列表项,右侧有一个收藏按钮,当用户点击收藏按钮时,会完成收藏,还是进入文章页面?
答案很明显,是完成收藏,因为用户就是想要点击收藏按钮,这对应了 Main 阶段,(收藏按钮)子组件会优先消费指针事件变化,(列表项)父组件在 Main 阶段看到事件已被消费了,就不会触发列表项的点击。
但不是永远都是子组件优先消费,有时也是父组件优先。比如在下图中,用户按下图片并开始拖动,会进入图片的预览,还是向上滑动列表?
答案也很明显,是向上滑动,这对应了 Initial 阶段,列表父组件会在 Initial 阶段检测到与拖动相关的事件,并消费掉这些事件变化,(图片)子组件看到拖动相关的变化已经被消费了,就不会处理拖动,图片也就不会响应点击导致进入预览界面了。
上述的例子中,假如图片在被按下时会有一个按下的视觉效果(如蒙层),那么用户按下图片并开始拖动,图片需要在 Final 阶段感知到拖动事件已被消费了。要取消自身的点击或长按手势的识别,然后移除视觉效果。这就是 Final 阶段的核心作用,确保子组件能够响应父组件的行为,实现状态同步和取消手势。
事件的消费
消费一个指针事件的变化,就是调用 PointerInputChange 对象的 consume() 方法,将表示是否被消费的属性值设置 true(已消费)。
知道了这些,使用原则也很简单,两点:
-
使用完触摸点的变化后,就标记为已消费
-
使用前,先检查事件是否已被消费
有些特殊情况下,可以不管已消费的标记。比如,一个父滚动容器即使发现子组件消费了按下事件,它也会记录这个按下事件的初始位置,以便后续可能会使用到这个位置(滚动的起点)。但对于移动事件,如果已被内部的滚动子组件消费,父滚动组件一般会放弃处理,让内层优先滚动。
代码层面
使用起来非常简单。
前面获取事件使用的是 awaitPointerEvent 函数,其实它还有一个 pass 参数,表示的是当前监听哪个流程。
suspend fun awaitPointerEvent(
pass: PointerEventPass = PointerEventPass.Main
): PointerEvent
// PointerEventPass 是一个枚举类
enum class PointerEventPass {
Initial,
Main,
Final
}
你只要改变 pass 的值,就可以在不同的阶段获取事件,并且获取的都是同一个指针事件。比如:
awaitEachGesture {
val event1 = awaitPointerEvent(PointerEventPass.Initial)
val event2 = awaitPointerEvent(PointerEventPass.Main)
val event3 = awaitPointerEvent(PointerEventPass.Final)
if (event1 == event2 && event2 == event3){
println("获取到的是同一个指针事件")
}
}
如果要消费事件的触摸点变化,只需调用 PointerInputChange 对象的 consume() 方法。检查某个触摸点是否被消费,就看它的 isConsumed 属性。
pointerInput(Unit) {
awaitEachGesture {
val event = awaitPointerEvent(PointerEventPass.Main)
event.changes.forEach { pointerInputChange ->
if (!pointerInputChange.isConsumed) { // 触摸点变化未被消费
pointerInputChange.consume() // 消费这个触摸点的变化
}
}
}
}
实现多指手势
实现多指手势只需对每个触摸点的变化信息(PointerEvent.changes 列表中的每个 PointerInputChange 对象)做综合计算就行了。
比如:
- 计算多指中心点: 遍历
changes列表,将所有触摸点的position相加,然后除以触摸点的数量,得到平均中心点。 - 计算多指平均位移: 遍历
changes,累加每个触摸点的positionChange()(即相对上一事件的位移变化量),然后求平均值。 - 计算缩放比例: 跟踪两个或多个特定手指,计算它们之间当前距离与上一事件瞬间的距离的比值。
- 计算旋转角度: 跟踪两个特定手指构成的向量,计算该向量当前方向与上一事件瞬间的方向之间的夹角。
并且 Compose 提供了预设的 API,方便我们计算:
pointerInput(Unit) {
awaitEachGesture {
val event = awaitPointerEvent(PointerEventPass.Main)
event.calculateCentroid() // 计算中心点
event.calculatePan() // 计算平均位移
event.calculateZoom() // 计算缩放比例
event.calculateRotation() // 计算旋转角度
}
}