Jetpack Compose 手势处理全解析:从 detectTransformGestures 到 100% 自定义

509 阅读14分钟

多指手势识别: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,此时平移、缩放和旋转手势都可以被同时识别,这意味着用户在进行捏合缩放图片时,如果手指不小心轻微移动或旋转,panzoomrotation 参数都会变化。

如果参数值为 true,旋转和平移/缩放操作就互斥了。

  • 如果用户的手势先被识别为了旋转(旋转角度变化量先达到定义的阈值),那么在所有手指抬起离开屏幕之前,所有的平移、放缩行为将不被识别,具体来说就是 panzoom 参数的值是不变的,pan 的值为 Offset.Zerozoom 的值为 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 来完成手势的识别,比如detectTapGesturesdetectDragGesturesdetectTransformGestures,现在我们来直接处理最底层的指针事件,来自定义手势的识别算法。

依旧使用的是 Modifier.pointerInput,因为它是处理所有指针事件的入口。

Modifier.pointerInput(key1 = Unit){
    // 手势处理的协程代码
}

再次简单介绍一下 pointerInput

  1. key 参数的值改变时,内部的协程会被取消并重启,可用于改变手势识别逻辑。
  2. 尾 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    你点击了文字
...

在以上的基础上,添加一个要求:如果手指按下后,在抬起前移出了组件的范围内,就不算一次点击。哪怕手指抬起时,触摸点在组件的范围内,也不算一次有效的点击。

完成只需两步:

  1. 监听初始的按下事件
  2. 然后在手指抬起前,持续进行监听事件:如果是移动事件,就看看有没有出组件的范围,出了就结束监听,取消当前手势;如果是抬起事件,就完成一次有效的点击,也结束监听。
@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 组件树的传递:

  1. Initial 阶段 (父组件 -> 子组件):

    事件首先被传递给最外层的父组件,然后逐级向下传递到实际触摸点所在的最内层的子组件。在这个传递过程中,每一级组件都可以获取并选择消费这个事件中的某些变化,也就是父组件可以优先处理事件。即使某个触摸点的变化被父组件消费了,事件依然会被向下传递给子组件,但这个事件会被标记上“已消费”,子组件可以看到哪些事件已被消费了。

  2. Main 阶段 (子组件 -> 父组件):

    当事件到了最内层的目标子组件后,它会从子组件开始逐级向上传递到父组件。awaitPointerEvent() 默认监听的就是这个阶段的事件。大多数手势逻辑(如识别点击、长按、拖动等)的实际响应和消费行为都发生在此阶段,如果某个子组件消费了某个触摸点的变化,父组件也能看到这个事件已被消费了的标记。

  3. Final 阶段 (父组件 -> 子组件):

    Main 阶段结束后,事件会再次从父组件向子组件向下传递。这个阶段非常重要,用来处理手势冲突,特别是手势取消的逻辑。子组件会在这个阶段检查事件中触摸点的消费状态,如果它发现它所依赖的某个触摸点的变化在 Main 阶段被其某个父组件消费了,那么子组件会在当前阶段取消自己当前正在进行的手势识别。

为什么是这样的流程? 举几个例子:

比如下面这是一个文章列表项,右侧有一个收藏按钮,当用户点击收藏按钮时,会完成收藏,还是进入文章页面?

image.png

答案很明显,是完成收藏,因为用户就是想要点击收藏按钮,这对应了 Main 阶段,(收藏按钮)子组件会优先消费指针事件变化,(列表项)父组件在 Main 阶段看到事件已被消费了,就不会触发列表项的点击。

但不是永远都是子组件优先消费,有时也是父组件优先。比如在下图中,用户按下图片并开始拖动,会进入图片的预览,还是向上滑动列表?

image.png

答案也很明显,是向上滑动,这对应了 Initial 阶段,列表父组件会在 Initial 阶段检测到与拖动相关的事件,并消费掉这些事件变化,(图片)子组件看到拖动相关的变化已经被消费了,就不会处理拖动,图片也就不会响应点击导致进入预览界面了。

上述的例子中,假如图片在被按下时会有一个按下的视觉效果(如蒙层),那么用户按下图片并开始拖动,图片需要在 Final 阶段感知到拖动事件已被消费了。要取消自身的点击或长按手势的识别,然后移除视觉效果。这就是 Final 阶段的核心作用,确保子组件能够响应父组件的行为,实现状态同步和取消手势。

事件的消费

消费一个指针事件的变化,就是调用 PointerInputChange 对象的 consume() 方法,将表示是否被消费的属性值设置 true(已消费)。

知道了这些,使用原则也很简单,两点:

  1. 使用完触摸点的变化后,就标记为已消费

  2. 使用前,先检查事件是否已被消费

有些特殊情况下,可以不管已消费的标记。比如,一个父滚动容器即使发现子组件消费了按下事件,它也会记录这个按下事件的初始位置,以便后续可能会使用到这个位置(滚动的起点)。但对于移动事件,如果已被内部的滚动子组件消费,父滚动组件一般会放弃处理,让内层优先滚动。

代码层面

使用起来非常简单。

前面获取事件使用的是 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() // 计算旋转角度
        
    }
}