Reputation: 1550
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?
Upvotes: 3
Views: 1155
Reputation: 67443
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()
Upvotes: 7
Reputation: 16162
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:
pointerInput
back using a similar function to calculateRotationAngle
.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.
Upvotes: 1