I’m migrating one of our app to compose, and i’m having trouble with a key feature. I want to create a composable that represents an element with a main body and two anchors. Here’s what i need it to do:
- The element can be moved by dragging the main body
- It can be resized by dragging either of the two anchors
- It can be rotated by dragging one anchor around the other
Here’s a demo of what i’m trying to achieve:
I’ve implemented most of the functionality, but i’m stuck on resizing the element from anchor 1 (left side).
Here’s what i’ve done so far:
Below is the code i’m working with:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import java.lang.Math.toDegrees
import java.lang.Math.toRadians
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Scaffold { paddingValues ->
Box(modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Element()
}
}
}
}
}
@Composable
fun Element() {
var position by remember { mutableStateOf(Offset(100f, 100f)) }
var size by remember { mutableStateOf(Size(300f, 60f)) }
var rotation by remember { mutableFloatStateOf(0f) }
var pivot by remember { mutableStateOf(Offset.Zero) }
// We use here `rememberUpdatedState` to be able
// to capture this variable inside `pointerInput`
// lambda
val anchor1 by rememberUpdatedState(
Rect(
offset = Offset(0f, 0f),
size = Size(size.height, size.height)
)
)
val anchor2 by rememberUpdatedState(
Rect(
offset = Offset(size.width - size.height, 0f),
size = Size(size.height, size.height)
)
)
val strokeColor = Color.Black
Box(modifier = Modifier
.size(size.toDp())
.graphicsLayer(
translationX = position.x,
translationY = position.y,
rotationZ = rotation,
transformOrigin = pivot.toTransformOrigin(size)
)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
position += dragAmount.rotateBy(rotation)
}
}
) {
//Element body
Box(
modifier = Modifier
.size(size.toDp())
.border(width = 1.dp, color = strokeColor)
.drawBehind {
drawLine(
color = strokeColor,
start = anchor1.center,
end = anchor2.center,
strokeWidth = 1.dp.toPx()
)
}
)
// Anchor 1 (left side)
Box(modifier = Modifier
.size(anchor1.size.toDp())
.align(Alignment.CenterStart)
.border(width = 1.dp, color = strokeColor, shape = CircleShape)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
val c1 = anchor1.center
val c2 = anchor2.center
val w = abs(c1.x - c2.x)
// Set pivot to anchor 2 (right side) and adjust position to prevent jump
if (pivot != c2) {
pivot = c2
val radian = toRadians(rotation.toDouble())
position += Offset(
w * (cos(radian).toFloat() - 1),
w * sin(radian).toFloat()
)
}
},
onDrag = { change, dragAmount ->
change.consume()
val c1 = anchor1.center
// Adjust the width
//val newWidth = size.width - dragAmount.x
//size = size.copy(width = newWidth)
// Adjust the position to keep pin 2 in place
//position += Offset(dragAmount.x, 0f).rotateBy(rotation)
rotation += pivot.angleBetween(c1, c1 + dragAmount)
}
)
}
)
// Anchor 2 (right side)
Box(modifier = Modifier
.size(anchor2.size.toDp())
.align(Alignment.CenterEnd)
.border(width = 1.dp, color = strokeColor, shape = CircleShape)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
val c1 = anchor1.center
val c2 = anchor2.center
val w = abs(c1.x - c2.x)
if (pivot != c1) {
pivot = c1
val radian = toRadians(rotation.toDouble())
position -= Offset(
w * (cos(radian).toFloat() - 1),
w * sin(radian).toFloat()
)
}
},
onDrag = { change, dragAmount ->
change.consume()
val c2 = anchor2.center
// Adjust the width
size = size.copy(width = size.width + dragAmount.x)
rotation += pivot.angleBetween(c2, c2 + dragAmount)
}
)
}
)
}
}
// -------------------- Utilities methods ------------------------------
fun Offset.toTransformOrigin(size: Size) = TransformOrigin(
pivotFractionX = x / size.width,
pivotFractionY = y / size.height
)
// Calculate the angle between two points relative to this Offset
fun Offset.angleBetween(p1: Offset, p2: Offset) : Float {
// Calculate the initial angle
val initialAngle = atan2(p1.y - y, p1.x - x)
// Calculate the new angle
val newAngle = atan2(p2.y - y, p2.x - x)
// Calculate the angle difference in degrees
val angleDifference = toDegrees((newAngle - initialAngle).toDouble()).toFloat()
return angleDifference
}
// Rotate an Offset by the given angle
fun Offset.rotateBy(
angle: Float
): Offset {
val angleInRadians = toRadians(angle.toDouble())
val cosAngle = cos(angleInRadians)
val sinAngle = sin(angleInRadians)
val newX = x * cosAngle - y * sinAngle
val newY = x * sinAngle + y * cosAngle
return Offset(newX.toFloat(), newY.toFloat())
}
@Composable
fun Size.toDp() = with(LocalDensity.current) {
DpSize(
width = width.toDp(),
height = height.toDp()
)
}
I’ve tried implementing resizing for anchor 1, but it’s not working as expected:
// Anchor 1 (left side)
Box(modifier = Modifier
//...
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
//...
},
onDrag = { change, dragAmount ->
change.consume()
val c1 = anchor1.center
// Adjust the width
val newWidth = size.width - dragAmount.x
size = size.copy(width = newWidth)
// Adjust the position to keep pin 2 fix
position += Offset(dragAmount.x, 0f).rotateBy(rotation)
rotation += pivot.angleBetween(c1, c1 + dragAmount)
}
)
}
)
I’ve tried multiple approaches, but resizing from anchor 1 is still not working. Any ideas on how i can fix this?
Related questions:
- How to create a draggable and rotatable box in Jetpack Compose?
- Issue with Custom View Position Jump when Changing Pivot Point
- How can I fix the position of a custom view while rotating it from its anchors in Android?
- How to correctly rotate a QGraphicsItem around different anchors in Qt C++