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:
I'm sure I'm missing something. Can anyone please help me with this?
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()?
@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()