androidandroid-jetpack-composeandroid-jetpack-compose-gesture

How to have natural pan and zoom with Modifier.graphicsLayer{}.pointerInput()?


This is not the duplicate of using Modifier.graphicsLayer{} to rotate or pan Image. I want move, rotate and scale a Composable on screen not in a stationary position.

What i want to achieve is as in this image

enter image description here

Order of Modifier.graphicsLayer{} and Modifier.pointerInput() matter. If Modifier.pointerInput() is placed after Modifier.graphicsLayer{} Composable's touch area is scaled, translated and rotated.

@Composable
private fun Test() {

    var offsetLeft by remember { mutableStateOf(Offset.Zero) }
    var offsetRight by remember { mutableStateOf(Offset.Zero) }

    var rotationLeft by remember { mutableStateOf(0f) }
    var rotationRight by remember { mutableStateOf(0f) }

    val modifierLeft = Modifier
        .border(3.dp, Color.Red)
        .graphicsLayer {
            translationX = offsetLeft.x
            translationY = offsetLeft.y
            rotationZ = rotationLeft
        }
        .border(2.dp, Color.Green)
        .pointerInput(Unit) {
            detectTransformGestures { centroid, pan, zoom, rotation ->
                offsetLeft += pan
                rotationLeft += rotation
            }
        }


    val modifierRight = Modifier
        .border(3.dp, Color.Red)
        .pointerInput(Unit) {
            detectTransformGestures { centroid, pan, zoom, rotation ->
                offsetRight += pan
                rotationRight += rotation
            }
        }
        .border(2.dp, Color.Green)
        .graphicsLayer {
            translationX = offsetRight.x
            translationY = offsetRight.y
            rotationZ = rotationRight
        }


    Row(modifier = Modifier.fillMaxSize()) {
        Image(
            painter = painterResource(id = R.drawable.landscape1),
            contentDescription = "",
            modifierLeft.aspectRatio(1f).weight(1f)
        )
        Image(
            painter = painterResource(id = R.drawable.landscape1),
            contentDescription = "",
            modifierRight.aspectRatio(1f).weight(1f)
        )
    }
}

I want to be able to move, rotate and a Composable like the left Image in gif from its current transformation.

enter image description here

What i did so far, i rotate pan by Rotation Matrix via

/**
 * Rotates the given offset around the origin by the given angle in degrees.
 *
 * A positive angle indicates a counterclockwise rotation around the right-handed 2D Cartesian
 * coordinate system.
 *
 * See: [Rotation matrix](https://en.wikipedia.org/wiki/Rotation_matrix)
 */
fun Offset.rotateBy(
    angle: Float
): Offset {
    val angleInRadians = ROTATION_CONST * angle
    val newX = x * cos(angleInRadians) - y * sin(angleInRadians)
    val newY = x * sin(angleInRadians) + y * cos(angleInRadians)
    return Offset(newX, newY)
}

internal const val ROTATION_CONST = (Math.PI / 180f).toFloat()

It works for panning, pan works fine but as translation from original position increase rotation and scaling doesn't seem to work. I'm not able translate position correctly.

@Composable
private fun MyComposable() {

    var offset by remember { mutableStateOf(Offset.Zero) }
    var zoom by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var text by remember { mutableStateOf("") }
    var centroid by remember { mutableStateOf(Offset.Zero) }


    val modifier = Modifier
        .border(3.dp, Color.Green)
        .fillMaxWidth()
        .aspectRatio(4 / 3f)
        .graphicsLayer {
            this.translationX = offset.x * zoom
            this.translationY = offset.y * zoom
            this.scaleX = zoom
            this.scaleY = zoom
            this.rotationZ = rotation
//            TransformOrigin(0f, 0f)
        }
        .pointerInput(Unit) {
            detectTransformGestures(
                onGesture = { gestureCentroid, gesturePan, gestureZoom, gestureRotate ->

                    rotation += gestureRotate
                    zoom *= gestureZoom
                    offset += gesturePan.rotateBy(rotation)
                    centroid = gestureCentroid

                }
            )
        }
        .drawWithContent {
            drawContent()
            drawCircle(color = Color.Red, center = centroid, radius = 20f)
        }

    Image(
        modifier = modifier,
        painter = painterResource(id = R.drawable.landscape1),
        contentDescription = null,
        contentScale = ContentScale.FillBounds
    )
}

enter image description here

I tried changing TransformOrigin(). Also updated sample code that works for stationary touch position, this is modified version of the one available in developer.android page

@Composable
private fun MyComposable3() {

    var zoom by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var centroid by remember { mutableStateOf(Offset.Zero) }
    var centroidOld by remember { mutableStateOf(Offset.Zero) }
    var centroidNew by remember { mutableStateOf(Offset.Zero) }
    var angle by remember { mutableStateOf(0f) }


    val imageModifier = Modifier

        .border(3.dp, Color.Green)
        .graphicsLayer {
            translationX = -offset.x * zoom
            translationY = -offset.y * zoom
            scaleX = zoom
            scaleY = zoom
            rotationZ = angle
            TransformOrigin(0f, 0f).also { transformOrigin = it }
        }
        .pointerInput(Unit) {
            detectTransformGestures(
                onGesture = { gestureCentroid, gesturePan, gestureZoom, gestureRotate ->
                    val oldScale = zoom
                    val newScale = (zoom * gestureZoom).coerceIn(0.5f..5f)

                    angle += gestureRotate

                    // For natural zooming and rotating, the centroid of the gesture should
                    // be the fixed point where zooming and rotating occurs.

                    // We compute where the centroid was (in the pre-transformed coordinate
                    // space),
                    // and then compute where it will be after this delta.

                    // We then compute what the new offset should be to keep the centroid
                    // visually stationary for rotating and zooming, and also apply the pan.

                    centroidOld = (offset + gestureCentroid / oldScale).rotateBy(gestureRotate)
                    centroidNew =
                        (gestureCentroid / newScale + gesturePan.rotateBy(angle) / oldScale)

                    offset = centroidOld - centroidNew

                    zoom = newScale

                    centroid = gestureCentroid
                }
            )
        }


        .border(3.dp, Color.Red)
        .drawWithContent {
            drawContent()
            drawCircle(color = Color.Red, center = centroid, radius = 20f)
            drawCircle(color = Color.Green, center = centroidOld, radius = 20f)
            drawCircle(color = Color.Blue, center = centroidNew, radius = 20f)
        }

    Image(
        modifier = imageModifier
            .fillMaxWidth()
            .aspectRatio(4 / 3f),
        painter = painterResource(id = R.drawable.landscape2),
        contentDescription = null,
        contentScale = ContentScale.FillBounds
    )
}

This one is not working either.


Solution

  • The problem is here:

    .graphicsLayer {
        this.translationX = offset.x*zoom
        this.translationY = offset.y*zoom
        this.scaleX = zoom
        this.scaleY = zoom
        this.rotationZ = rotation
    }
    

    Remove the zoom multiplication from this part and change it to:

    .graphicsLayer {
        this.translationX = offset.x
        this.translationY = offset.y
        this.scaleX = zoom
        this.scaleY = zoom
        this.rotationZ = rotation
    }
    

    Do the multiplication in the detectTransformGestures function instead:

    .pointerInput(Unit) {
        detectTransformGestures(
            onGesture = { gestureCentroid, gesturePan, gestureZoom, gestureRotate ->
                rotation += gestureRotate
                zoom *= gestureZoom
                offset += gesturePan.rotateBy(rotation)*zoom
                centroid = gestureCentroid
            }
        )
    }
    

    Android logo

    Here's the full updated code:

    @Composable
    private fun MyComposable() {
    
        var offset by remember { mutableStateOf(Offset.Zero) }
        var zoom by remember { mutableStateOf(1f) }
        var rotation by remember { mutableStateOf(0f) }
        var text by remember { mutableStateOf("") }
        var centroid by remember { mutableStateOf(Offset.Zero) }
    
    
        val modifier = Modifier
            .border(3.dp, Color.Green)
            .fillMaxWidth()
            .aspectRatio(4 / 3f)
            .graphicsLayer {
                this.translationX = offset.x
                this.translationY = offset.y
                this.scaleX = zoom
                this.scaleY = zoom
                this.rotationZ = rotation
    //            TransformOrigin(0f, 0f)
            }
            .pointerInput(Unit) {
                detectTransformGestures(
                    onGesture = { gestureCentroid, gesturePan, gestureZoom, gestureRotate ->
                        rotation += gestureRotate
                        zoom *= gestureZoom
                        offset += gesturePan.rotateBy(rotation)*zoom
                        centroid = gestureCentroid
                    }
                )
            }
            .drawWithContent {
                drawContent()
                drawCircle(color = Color.Blue, center = centroid, radius = 20f)
            }
    
        Image(
            modifier = modifier.background(Color.Gray),
            painter = painterResource(id = R.drawable.img),
            contentDescription = null,
            contentScale = ContentScale.FillBounds
        )
    }
    

    Edit : make the image stationary when rotating or zooming

    .pointerInput(Unit) {
        detectTransformGestures(
            onGesture = { gestureCentroid, gesturePan, gestureZoom, gestureRotate ->
                rotation += gestureRotate
                zoom *= gestureZoom
    
                if (gestureRotate == 0f && gestureZoom == 1f) { // only allow movement if not rotating or zooming
                    offset += gesturePan.rotateBy(rotation) * zoom
                }
                centroid = gestureCentroid
            }
        )
    }