• Small knowledge, big challenge! This article is participating in the creation activity of “Essential Tips for Programmers”.
  • This article has participated in the “Digitalstar Project” and won a creative gift package to challenge the creative incentive money.

1. Introduction

It is highly recommended to start by looking at the Compose official gesture document, which is extremely detailed and contains many examples. Be sure to read them all

This article is based on an example of the official gesture guide document (which is not the same document as the link at 👆👆). When I saw this example, I wanted to write this article because Compose is so easy to implement that I just wanted to use it, but……… The official example has a problem, originally thought that copy and paste run smoothly, it seems that I think too much, so first analyze the official example so write what is the problem

2. Problem analysis

Let’s take a look at the official, multi-touch: pan, zoom, rotate example

@Composable
fun TransformableSample(a) {
    // set up all transformation states
    var scale by remember { mutableStateOf(1f)}var rotation by remember { mutableStateOf(0f)}var offset by remember { mutableStateOf(Offset.Zero) }
    // The callback receives changes from the previous event, updating the status value in the 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()
    )
}
Copy the code

Well, I’m a little disappointed. After panning and scaling, the center point is not the same as the center point after panning and scaling. Looking at the official example, it is obvious that the two fingers do not follow the fingers when moving





Problematic effect

Follow us to analyze the source code implementation of the above example, why the combination of problems, please read down

3. Source code analysis

Compose UI: Compose UI: Compose UI: Compose UI: Compose UI: Compose UI: Compose UI: Compose UI

When we pinch and pan, the dispatchDraw method call from AndroidComposeView is executed, and measureAndLayout() is executed before layoutNode is drawn. After a series of method calls, Will perform to the SimpleGraphicsLayerModifier

//androidx.compose.ui.graphics.SimpleGraphicsLayerModifier
private class SimpleGraphicsLayerModifier(
    private val scaleX: Float.private val scaleY: Float,...) : LayoutModifier, InspectorValueInfo(inspectorInfo) {// Define layer parameters to skip reorganization and relayout when state changes occur
    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)
        }
    }
    ......
}
Copy the code

Official example, we see the using Modifier. GraphicsLayer, it returns is SimpleGraphicsLayerModifier, on a placeable. After placeWithLayer method, The placeAt method of LayoutNodeWrapper is triggered

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

OnLayerBlockUpdated (layerBlock)

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

I tested it on Android10.0, so the layer here uses 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()
}
Copy the code

TransformOrigin = transformOrigin.Center. We see that the pivotX and pivotY centers in the RenderNode are calculated to always be half the width and height of the RenderNode

renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width renderNode.pivotY = transformOrigin.pivotFractionY  * renderNode.heightCopy the code

We see high renderNode actual width is constant, center will not change, thought is a central problem, is actually a double refers to zoom mobile, rememberTransformableState callback offsetChange calculation has a problem, can’t achieve the result that we want, Let’s look at the Modifier. Transformable source code:

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

What’s happening in the detectZoom method

//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) } ...... }... }Copy the code

PastTouchSlop is true if pastTouchSlop is true PanMotion > touchSlop, if the conditions are not met, then the panChange value of this time will not be passed back, we do not use the panChange value here, next we will detect the gesture drag event, calculate the offset of movement, please continue to see 👇👇

4. Troubleshooting

After analyzing the content of the above, we can’t use rememberTransformableState callback offsetChange value, we need to calculate the Offset value, Then we need to use the Modifier. PointerInput to receive the data returned by the touch, PointerInputScope have an extension method can detect “level” and “vertical” at the same time the direction of the drag and drop the callback PointerInputScope. DetectDragGestures

If you only want to detect one direction, you can use the following two methods:

PointerInputScope.detectHorizontalDragGestures PointerInputScope.detectVerticalDragGestures

The onDrag callback returns the offset of “current position” – “previous position”. The final fix is as follows:

@Composable
fun TransformableExample(a) {
    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
        // Do not use offsetChange here
    }
    Box(
        Modifier
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    // Return the correct movement position, following the finger
                    offset +=dragAmount
                }
            }
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y,
            )
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}
Copy the code




The effect after repair

In addition, we can also use other ways to achieve, the following we provide you with other implementation source:

@Composable
fun TransformableExample2(a) {
    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()
    )
}
Copy the code

If you use Modifier scale/rotate/offset, note that the order is important:

1. If offset occurs before rotate, rotate affects the offset. The real effect is that when the drag gesture occurs, the component is offset by the current Angle. 2. If offset occurs before scale, scale also affects offset. The effect is that the UI component does not drag so when using Modifier, the recommended order of calls for offset, scale, and rotation is rotate -> scale -> offset


Previous articles recommended: 1. Practice the Jetpack SplashScreen API correctly — Summary for all Android systems Jetpack Compose processing “navigation bar, status bar, keyboard” affect content display problem collection 3.Android cross-process transmission thinking and implementation — attached principle analysis 4. 5.Jetpack Compose UI creation layout drawing process + principle – contains a detailed explanation of the concept (full of stuff) Startup How to use and working Principle 7. Source code analysis | ThreadedRenderer null pointer, the Choreographer meet 8, by the way. Source code analysis | events is how to the Activity? 9. CountDownLatch source code 10