Function

animateBy

Animate zoom, pan, and rotation simultaneously and suspend until the animation is finished.

TransformableAnimateBySample

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TransformableAnimateBySample() {
    /**
     * 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 ->
                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)
                // detect tap gestures:
                // 1) single tap to simultaneously animate zoom, pan, and rotation
                // 2) double tap to animate back to the initial position
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = { offset ->
                            coroutineScope.launch {
                                state.animateBy(
                                    zoomFactor = 1.5f,
                                    panOffset = Offset(20f, 20f),
                                    rotationDegrees = 90f,
                                    zoomAnimationSpec = spring(),
                                    panAnimationSpec = tween(durationMillis = 1000),
                                    rotationAnimationSpec = spring(),
                                    centroid = offset,
                                )
                            }
                        },
                        onDoubleTap = { offset ->
                            coroutineScope.launch {
                                state.animateBy(
                                    zoomFactor = 1 / scale,
                                    panOffset = -offset,
                                    rotationDegrees = -rotation,
                                    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),
            )
        }
    }
}