Compose Modifier

transformable

Enable transformation gestures of the modified UI element.

TransformableSample

@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),
            )
        }
    }
}

TransformableSampleInsideScroll

@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))
    }
}