Question

How to create a draggable and rotatable box in Jetpack Compose?

I'm working on a Jetpack Compose application and I want to create a Box that can be both dragged and rotated using mouse interactions. I should be able to click and drag the entire Box to move it around the screen. also I want to add a small handle at the top-center of the Box. When I drag this handle, the Box should rotate around its center.

Here's what I've tried so far:

@Composable
fun DragRotateBox() {

    var rotation by remember { mutableStateOf(0f) }
    var position by remember { mutableStateOf(Offset.Zero) }

    var initialTouch = Offset.Zero

    val boxSize = 100.dp
    val handleSize = 20.dp

    val boxSizePx = with(LocalDensity.current) { boxSize.toPx() }

    val center = Offset(boxSizePx, boxSizePx)

    // Main Box
    Box(
        modifier = Modifier
            .graphicsLayer(
                rotationZ = rotation,
                translationX = position.x,
                translationY = position.y
            )
            .background(Color.Blue)
            .size(boxSize)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDrag = {change, dragAmount ->
                        change.consume()
                        position += dragAmount
                    }
                )
            }
    ) {
        // Rotation handler
        Box(
            modifier = Modifier
                .size(handleSize)
                .background(Color.Red)
                .align(Alignment.TopCenter)
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { offset ->
                            initialTouch = offset
                        },
                        onDrag = { change, dragAmount ->
                            change.consume()

                            val angle = calculateRotationAngle(center, initialTouch, change.position)
                            rotation += angle
                        }
                    )
                }
        )
    }
}
// Generated by ChatGPT!    
fun calculateRotationAngle(pivot: Offset, initialTouch: Offset, currentTouch: Offset): Float {
    val initialVector = initialTouch - pivot
    val currentVector = currentTouch - pivot

    val initialAngle = atan2(initialVector.y, initialVector.x)
    val currentAngle = atan2(currentVector.y, currentVector.x)

    return Math.toDegrees((currentAngle - initialAngle).toDouble()).toFloat()
}

The dragging and the rotation work fine when implemented alone, but when I try to combine both dragging and rotating, the interactions do not work as expected.

Here is a demo of the issue:

enter image description here

I'm sure I'm missing something. Can anyone please help me with this?

 3  108  3
1 Jan 1970

Solution

 4

If you wish to apply any transformation to a Composable based on its dynamic position you need to apply Modifier.graphicsLayer before pointerInput. However in this case you need to calculate centroid translation accordingly.

Using rotation matrix to calculate correct position will fix the issue.

You can also refer my question which also adds zoom into the pan which makes the case hard but only with rotation and translation issue is not that complex.

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

enter image description here

@Preview
@Composable
fun DragRotateBox() {

    Column(
        modifier = Modifier.fillMaxSize()
    ) {
        var rotation by remember { mutableStateOf(0f) }
        var position by remember { mutableStateOf(Offset.Zero) }

        val boxSize = 100.dp
        val handleSize = 20.dp

        var initialTouch = Offset.Zero

        val boxSizePx = with(LocalDensity.current) { boxSize.toPx() }

        val center = Offset(boxSizePx, boxSizePx)


        // Main Box
        Box(
            modifier = Modifier
                .graphicsLayer(
                    rotationZ = rotation,
                    translationX = position.x,
                    translationY = position.y
                )
                .background(Color.Blue)
                .size(boxSize)
                .pointerInput(Unit) {
                    detectTransformGestures { _, pan, _, _ ->
                        position += pan.rotateBy(rotation)

                    }
                }
        ) {
            // Rotation handler
            Box(
                modifier = Modifier
                    .size(handleSize)
                    .background(Color.Red)
                    .align(Alignment.TopCenter)
                    .pointerInput(Unit) {
                        detectDragGestures(
                            onDragStart = { offset ->
                                initialTouch = offset
                            },
                            onDrag = { change, dragAmount ->
                                change.consume()
                                val angle = calculateRotationAngle(center, initialTouch, change.position)
                                rotation += angle
                            }
                        )
                    }
            )
        }
    }
}

// Generated by ChatGPT!
fun calculateRotationAngle(pivot: Offset, initialTouch: Offset, currentTouch: Offset): Float {
    val initialVector = initialTouch - pivot
    val currentVector = currentTouch - pivot

    val initialAngle = atan2(initialVector.y, initialVector.x)
    val currentAngle = atan2(currentVector.y, currentVector.x)

    return Math.toDegrees((currentAngle - initialAngle).toDouble()).toFloat()
}

/**
 * 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()
2024-07-10
Thracian

Solution

 1

When you apply the graphicsLayer modifier to rotate the blue box the coordinates of all following modifiers are affected too. When the box is rotated by 180° then up becomes down and left becomes right: Moving the box is now inverted.

There are generally two approaches to solve that:

  1. Translate the coordinates received by the blue box's pointerInput back using a similar function to calculateRotationAngle.
  2. Apply the pointerInput before the graphicsLayer messses with the coordinates.

I would prefer solution 2 because it is easier. But be aware: If you just move the blue box's pointerInput to the front of the modifier chain the drag gesture will only be detected when you click on the box's original position in the top-left. That is because not only the rotation isn't applied yet (what was intended), the positional translation isn't applied yet either so from the point of view of pointerInput the box never moved. Only after the detection of drag gestures the positional translation is applied in graphicsLayer.

To fix that you need to separate the positional translation and the rotation by applying one graphicsLayer modifier before pointerInput which only translates the position, and another one after to do the rotation. There actually aready is a dedicated offset modifier that can be used for positional translation and another one to just rotate the current element, so you should use these instead of graphicsLayer.

When you change the blue box's modifier chain to this everything should work as expected:

modifier = Modifier
    .offset { position.round() }
    .pointerInput(Unit) {
        detectDragGestures(
            onDrag = { change, dragAmount ->
                change.consume()
                position += dragAmount
            }
        )
    }
    .rotate(rotation)
    .background(Color.Blue)
    .size(boxSize)

I just realized: One caveat of this solution is that you can only grab the blue box by its original unrotated orientation, even if it is rotated: When rotated by 45° (with the corner pointing up) you cannot grab any of the blue corners, but you can grab a bit of the white background near the middle of the edges. Just imagine how the unrotated box would look like at that position, that is what can be grabbed.

That cannot be remedied while using my proposed solution 2, so you might need to use solution 1 after all.

2024-07-10
Leviathan