androidkotlinandroid-jetpack-composerotationcompose-desktop

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?


Solution

  • 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()