Enable transformation gestures of the modified UI element.
@Composable
fun TransformableSample() {
/**
* 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 = angle * (PI / 180)
val cos = cos(angleInRadians)
val sin = sin(angleInRadians)
return Offset((x * cos - y * sin).toFloat(), (x * sin + y * cos).toFloat())
}
Box(Modifier.size(200.dp).clipToBounds().background(Color.LightGray)) {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val coroutineScope = rememberCoroutineScope()
var size by remember { mutableStateOf(Size.Zero) }
// let's create a modifier state to specify how to update our UI state defined above
val state =
rememberTransformableState { centroid, zoomChange, offsetChange, rotationChange ->
// note: scale goes by factor, not an absolute difference, so we need to multiply it
// for this example, we don't allow downscaling, so cap it to 1f
val oldScale = scale
val newScale = max(scale * zoomChange, 1f)
// If the centroid isn't specified, assume it should be applied from the center
val effectiveCentroid = centroid.takeIf { it.isSpecified } ?: size.center
// For natural zooming and rotating, the centroid of the gesture should
// be the fixed point where zooming and rotating occurs.
// We compute where the centroid was (in the pre-transformed coordinate
// space), and then compute where it will be after this delta.
// We then compute what the new offset should be to keep the centroid
// visually stationary for rotating and zooming, and also apply the pan.
offset =
(offset + effectiveCentroid / oldScale).rotateBy(rotationChange) -
(effectiveCentroid / newScale + offsetChange / oldScale)
scale = newScale
rotation += rotationChange
}
Box(
Modifier.onSizeChanged { size = it.toSize() }
// add transformable to listen to multitouch transformation events after offset
.transformable(state = state)
// optional for example: add double click to zoom
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { offset ->
coroutineScope.launch { state.animateZoomBy(4f, centroid = offset) }
}
)
}
.fillMaxSize()
.border(1.dp, Color.Green)
) {
Text(
"\uD83C\uDF55",
fontSize = 32.sp,
// apply other transformations like rotation and zoom on the pizza slice emoji
modifier =
Modifier.fillMaxSize()
.graphicsLayer {
translationX = -offset.x * scale
translationY = -offset.y * scale
scaleX = scale
scaleY = scale
rotationZ = rotation
transformOrigin = TransformOrigin(0f, 0f)
}
.wrapContentSize(align = Alignment.Center),
)
}
}
}
@Composable
fun TransformableSampleInsideScroll() {
/**
* 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 = angle * (PI / 180)
val cos = cos(angleInRadians)
val sin = sin(angleInRadians)
return Offset((x * cos - y * sin).toFloat(), (x * sin + y * cos).toFloat())
}
Row(Modifier.size(width = 120.dp, height = 100.dp).horizontalScroll(rememberScrollState())) {
// first child of the scrollable row is a transformable
Box(Modifier.size(100.dp).clipToBounds().background(Color.LightGray)) {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val coroutineScope = rememberCoroutineScope()
var size by remember { mutableStateOf(Size.Zero) }
// let's create a modifier state to specify how to update our UI state defined above
val state =
rememberTransformableState { centroid, zoomChange, offsetChange, rotationChange ->
val oldScale = scale
val newScale = max(scale * zoomChange, 1f)
// If the centroid isn't specified, assume it should be applied from the center
val effectiveCentroid = centroid.takeIf { it.isSpecified } ?: size.center
// For natural zooming and rotating, the centroid of the gesture should
// be the fixed point where zooming and rotating occurs.
// We compute where the centroid was (in the pre-transformed coordinate
// space), and then compute where it will be after this delta.
// We then compute what the new offset should be to keep the centroid
// visually stationary for rotating and zooming, and also apply the pan.
offset =
(offset + effectiveCentroid / oldScale).rotateBy(rotationChange) -
(effectiveCentroid / newScale + offsetChange / oldScale)
scale = newScale
rotation += rotationChange
}
Box(
Modifier.onSizeChanged { size = it.toSize() }
// add transformable to listen to multitouch transformation events after offset
// To make sure our transformable work well within pager or scrolling lists,
// disallow panning if we are not zoomed in.
.transformable(state = state, canPan = { scale != 1f })
// optional for example: add double click to zoom
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { offset ->
coroutineScope.launch { state.animateZoomBy(4f, centroid = offset) }
}
)
}
.fillMaxSize()
.border(1.dp, Color.Green)
) {
Text(
"\uD83C\uDF55",
fontSize = 32.sp,
modifier =
Modifier.fillMaxSize()
.graphicsLayer {
translationX = -offset.x * scale
translationY = -offset.y * scale
scaleX = scale
scaleY = scale
rotationZ = rotation
transformOrigin = TransformOrigin(0f, 0f)
}
.wrapContentSize(align = Alignment.Center),
)
}
}
// other children are just colored boxes
Box(Modifier.size(100.dp).background(Color.Red).border(2.dp, Color.Black))
}
}