顺手修复了Jetpack Compose官方文档中的一个多点触控示例的Bug

2,716 阅读5分钟

1.前言

强烈建议先看: Compose官方手势文档,超详细,里面有很多示例,一定要全部看完,对你们有好处

本篇文章源起官方手势指南文档的示例(和👆👆上面的链接不是同一个文档),当时看到这个例子的时候,就想写这篇文章了,因为用Compose实现起来简直太简单了,想着拿来就用,不过.........官方示例有问题,原以为复制粘贴运行一气呵成,看来是我想多了,那么先分析一下官方示例这么写有什么问题吧

2.问题分析

我们来看一下官方的,多点触控:平移、缩放、旋转,示例存在的问题

@Composable
fun TransformableSample() {
    // set up all transformation states
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    // 回调接收来自前一个事件的变更,在lambda中更新状态值
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // apply other transformations like rotation and zoom
            // on the pizza slice emoji
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // add transformable to listen to multitouch transformation events
            // after offset
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

一眼望去,嗯,没啥大问题,运行一下:嗯,有点失望,缩放平移之后,中心点不是缩放平移后的中心点,看官方示例运行的效果,很明显双指移动的时候不跟随手指


有问题的效果

来跟着我们一起来分析一下上面示例的源码实现,为啥组合起来就有毛病呢,请往下看

3.源码分析

我们以前手撕过Compose UI创建布局绘制流程+原理,感兴趣的同学可以去看看学习一下

我们双指捏合缩放平移,会触发AndroidComposeViewdispatchDraw方法调用执行,在调用layoutNode绘制之前,会执行measureAndLayout(),经过一系列方法调用,会执行到SimpleGraphicsLayerModifier

//androidx.compose.ui.graphics.SimpleGraphicsLayerModifier
private class SimpleGraphicsLayerModifier(
    private val scaleX: Float,
    private val scaleY: Float,
    ......
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
    // 定义图层参数,在发生状态更改时跳过重组和重新布局
    private val layerBlock: GraphicsLayerScope.() -> Unit = {
        scaleX = this@SimpleGraphicsLayerModifier.scaleX
        scaleY = this@SimpleGraphicsLayerModifier.scaleY
        ......
    }
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        return layout(placeable.width, placeable.height) {
            placeable.placeWithLayer(0, 0, layerBlock = layerBlock)
        }
    }
    ......
}

官方示例中,我们看到使用了Modifier.graphicsLayer,它里面返回的就是SimpleGraphicsLayerModifier,在执行placeable.placeWithLayer方法之后,会触发LayoutNodeWrapperplaceAt方法执行

//androidx.compose.ui.node.LayoutNodeWrapper
override fun placeAt(
    position: IntOffset,
    zIndex: Float,
    layerBlock: (GraphicsLayerScope.() -> Unit)?
) {
    onLayerBlockUpdated(layerBlock)
    ......
}

onLayerBlockUpdated(layerBlock) 里面会触发如下方法执行:

//androidx.compose.ui.node.LayoutNodeWrapper
private fun updateLayerParameters() {
        ......
        layer.updateLayerProperties(
            scaleX = graphicsLayerScope.scaleX,
            scaleY = graphicsLayerScope.scaleY,
            alpha = graphicsLayerScope.alpha,
            translationX = graphicsLayerScope.translationX,
            translationY = graphicsLayerScope.translationY,
            ......
        )
        ......
}

我使用了Android10.0机器测试的,所以此处的layer使用的是RenderNodeLayer

//androidx.compose.ui.platform.RenderNodeLayer
override fun updateLayerProperties(
    scaleX: Float,
    scaleY: Float,
    alpha: Float,
    translationX: Float,
    translationY: Float,
    shadowElevation: Float,
    ......
) {
    renderNode.scaleX = scaleX
    renderNode.scaleY = scaleY
    ......
    renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
    renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height
    ......
    if (!drawnWithZ && renderNode.elevation > 0f) {
        invalidateParentLayer()
    }
    matrixCache.invalidate()
}

入参的transformOrigin = TransformOrigin.Center,看到这里,我们发现此处RenderNode里面的pivotX和pivotY中心点计算出来:永远是renderNode宽高的一半

renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height

我们看到renderNode的实际宽高是不变的,中心点不会变,原本以为是中心点的问题,实际上是双指缩放移动的时候,rememberTransformableState回调的offsetChange计算方式有问题,无法达到我们要的效果,我们来看一下Modifier.transformable源码:

//androidx.compose.foundation.gestures.TransformableKt
fun Modifier.transformable(
    state: TransformableState,
    lockRotationOnZoomPan: Boolean = false,
    enabled: Boolean = true
) = composed(
    ......
    forEachGesture {
         detectZoom(updatePanZoomLock, updatedState)
    }
    ......
)

detectZoom方法看里面做了什么

//androidx.compose.foundation.gestures.TransformableKt
private suspend fun PointerInputScope.detectZoom(
    panZoomLock: State<Boolean>,
    state: State<TransformableState>
) {
    ......
    val panChange = event.calculatePan()
    val panMotion = pan.getDistance()
    
    if (zoomMotion > touchSlop ||
        rotationMotion > touchSlop ||
        panMotion > touchSlop
    ) {
        pastTouchSlop = true
        lockedToPanZoom = panZoomLock.value && rotationMotion < touchSlop
    }
    if (pastTouchSlop) {
        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
        if (effectiveRotation != 0f ||
            zoomChange != 1f ||
            panChange != Offset.Zero
        ) {
            transformBy(zoomChange, panChange, effectiveRotation)
        }
        ......
    }
    ......
}

我们看上面的pastTouchSlop为true的条件,条件之一就是:panMotion > touchSlop,如果条件都不满足的话,那么本次的panChange值就无法传递回来,我们不使用这里面的panChange值,下面我们自己来检测手势拖拽事件,计算移动的偏移量,请继续往下看👇👇

4.问题修复

分析完上面的内容之后,我们不能再使用 rememberTransformableState回调提供的offsetChange值了,我们需要重新计算Offset值,那么我们需要用Modifier.pointerInput 来接收触摸返回的数据,PointerInputScope有一个扩展方法可以同时检测“水平”和“垂直”方向的拖拽回调PointerInputScope.detectDragGestures

如果只想检测一个方向可以使用以下两个方法:

PointerInputScope.detectHorizontalDragGestures PointerInputScope.detectVerticalDragGestures

在onDrag回调方法里面回返回:“当前位置” - “前一个位置”的偏移量,我们来看最终修复的方法如下:

@Composable
fun TransformableExample() {
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        //不要使用此处的offsetChange
    }
    Box(
        Modifier
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    // 返回正确的移动位置,跟随手指
                    offset +=dragAmount
                }
            }
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y,
            )
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

修复后的效果

另外我们还可以使用其他方式实现,下面我们给大家提供其他的实现方式源码:

@Composable
fun TransformableExample2() {
    var zoom by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }
    val offsetX = remember { mutableStateOf(0f) }
    val offsetY = remember { mutableStateOf(0f) }
    Box(
        Modifier
            .rotate(angle)
            .scale(zoom)
            .offset {
                IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt())
            }
            .background(Color.Blue)
            .pointerInput(Unit) {
                forEachGesture {
                    awaitPointerEventScope {
                        awaitFirstDown()
                        do {
                            val event = awaitPointerEvent()
                            val offset = event.calculatePan()
                            offsetX.value += offset.x
                            offsetY.value += offset.y
                            val rotation = event.calculateRotation()
                            angle += rotation
                            zoom *= event.calculateZoom()
                        } while (event.changes.any { it.pressed })
                    }
                }
            }
            .fillMaxSize()
    )
}

如果使用Modifierscale/rotate/offset需要注意一点,顺序很重要

1.如果offset发生在rotate之前,rotate会对offset造成影响。实际效果会出现: 当出现拖动手势时,组件会以当前角度为坐标轴进行偏移。
2.如果offset发生在scale之前,scale也会对offset造成影响。实际效果会出现: UI组件在拖动时不跟手
所以使用Modifier的时候,关于偏移、缩放与旋转,我们建议的调用顺序是 rotate -> scale -> offset


往期文章推荐:
1.正确实践Jetpack SplashScreen API —— 在所有Android系统上使用总结,内含原理分析
2.Jetpack Compose处理“导航栏、状态栏、键盘” 影响内容显示的问题集锦
3.Android跨进程传大图思考及实现——附上原理分析
4.闲聊Android悬浮的“系统文本选择菜单”和“ActionMode解析”——附上原理分析 5.Jetpack Compose UI创建布局绘制流程+原理 —— 内含概念详解(满满干货)
6.Jetpack App Startup如何使用及原理分析
7.源码分析 | ThreadedRenderer空指针问题,顺便把Choreographer认识一下
8.源码分析 | 事件是怎么传递到Activity的?
9.聊聊CountDownLatch 源码
10.Android正确的保活方案,不要掉进保活需求死循环陷进