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

809 Views Asked by At

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.

1

There are 1 best solutions below

0
On

You won't succeed to modify the composable placement with the graphicLayer modifier, the doc says :

Modifier.graphicsLayer does not change the measured size or placement of your composable, as it only affects the draw phase. This means that your composable might overlap others if it ends up drawing outside of its layout bounds.

Try to use the layout modifier instead :

var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val transformationState = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
    scale *= zoomChange
    rotation += rotationChange
    offset += offsetChange
}
val modifier = Modifier
    .rotate(rotation)
    .scale(scale)
    .layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)
        layout(
            placeable.width,
            placeable.height
        ) {
            this.coordinates
            placeable.placeRelative(offset.toIntOffset())
        }
    }
    .transformable(state = transformationState)
    .border(2.dp, Color.Green)

You can find more informations here.