TransformableState


State of transformable. Allows for a granular control of how different gesture transformations are consumed by the user as well as to write custom transformation methods using transform suspend function.

Summary

Public functions

suspend Unit
transform(transformPriority: MutatePriority, block: suspend TransformScope.() -> Unit)

Call this function to take control of transformations and gain the ability to send transform events via TransformScope.transformBy.

Cmn

Public properties

Boolean

Whether this TransformableState is currently transforming by gesture or programmatically or not.

Cmn

Extension functions

suspend Unit
TransformableState.animateBy(
    zoomFactor: Float,
    panOffset: Offset,
    rotationDegrees: Float,
    zoomAnimationSpec: AnimationSpec<Float>,
    panAnimationSpec: AnimationSpec<Offset>,
    rotationAnimationSpec: AnimationSpec<Float>,
    centroid: Offset
)

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

Cmn
suspend Unit
TransformableState.animatePanBy(
    offset: Offset,
    animationSpec: AnimationSpec<Offset>,
    centroid: Offset
)

Animate pan by offset Offset in pixels and suspend until its finished

Cmn
suspend Unit
TransformableState.animateRotateBy(
    degrees: Float,
    animationSpec: AnimationSpec<Float>,
    centroid: Offset
)

Animate rotate by a ratio of degrees clockwise and suspend until its finished.

Cmn
suspend Unit
TransformableState.animateZoomBy(
    zoomFactor: Float,
    animationSpec: AnimationSpec<Float>,
    centroid: Offset
)

Animate zoom by a ratio of zoomFactor over the current size and suspend until its finished.

Cmn
suspend Unit
TransformableState.panBy(offset: Offset, centroid: Offset)

Pan without animation by a offset Offset in pixels and suspend until it's set.

Cmn
suspend Unit
TransformableState.rotateBy(degrees: Float, centroid: Offset)

Rotate without animation by a degrees degrees and suspend until it's set.

Cmn
suspend Unit

Stop and suspend until any ongoing TransformableState.transform with priority terminationPriority or lower is terminated.

Cmn
suspend Unit
TransformableState.zoomBy(zoomFactor: Float, centroid: Offset)

Zoom without animation by a ratio of zoomFactor over the current size and suspend until it's set.

Cmn

Public functions

transform

suspend fun transform(
    transformPriority: MutatePriority = MutatePriority.Default,
    block: suspend TransformScope.() -> Unit
): Unit

Call this function to take control of transformations and gain the ability to send transform events via TransformScope.transformBy. All actions that change zoom, pan or rotation values must be performed within a transform block (even if they don't call any other methods on this object) in order to guarantee that mutual exclusion is enforced.

If transform is called from elsewhere with the transformPriority higher or equal to ongoing transform, ongoing transform will be canceled.

Public properties

isTransformInProgress

val isTransformInProgressBoolean

Whether this TransformableState is currently transforming by gesture or programmatically or not.

Extension functions

suspend fun TransformableState.animateBy(
    zoomFactor: Float,
    panOffset: Offset,
    rotationDegrees: Float,
    zoomAnimationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow),
    panAnimationSpec: AnimationSpec<Offset> = SpringSpec(stiffness = Spring.StiffnessLow),
    rotationAnimationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow),
    centroid: Offset = Offset.Unspecified
): Unit

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

Zoom is animated by a ratio of zoomFactor over the current size. Pan is animated by panOffset in pixels. Rotation is animated by the value of rotationDegrees clockwise. Any of these parameters can be set to a no-op value that will result in no animation of that parameter. The no-op values are the following: 1f for zoomFactor, Offset.Zero for panOffset, and 0f for rotationDegrees.

import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.animateBy
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.geometry.isSpecified
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.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize

/**
 * 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),
        )
    }
}
Parameters
zoomFactor: Float

ratio over the current size by which to zoom. For example, if zoomFactor is 3f, zoom will be increased 3 fold from the current value.

panOffset: Offset

offset to pan, in pixels

rotationDegrees: Float

the degrees by which to rotate clockwise

zoomAnimationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)

AnimationSpec to be used for animating zoom

panAnimationSpec: AnimationSpec<Offset> = SpringSpec(stiffness = Spring.StiffnessLow)

AnimationSpec to be used for animating offset

rotationAnimationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)

AnimationSpec to be used for animating rotation

centroid: Offset = Offset.Unspecified

the Offset around which the animation should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.

suspend fun TransformableState.animatePanBy(
    offset: Offset,
    animationSpec: AnimationSpec<Offset> = SpringSpec(stiffness = Spring.StiffnessLow),
    centroid: Offset = Offset.Unspecified
): Unit

Animate pan by offset Offset in pixels and suspend until its finished

Parameters
offset: Offset

offset to pan, in pixels

animationSpec: AnimationSpec<Offset> = SpringSpec(stiffness = Spring.StiffnessLow)

AnimationSpec to be used for pan animation

centroid: Offset = Offset.Unspecified

the Offset around which the pan should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.

animateRotateBy

suspend fun TransformableState.animateRotateBy(
    degrees: Float,
    animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow),
    centroid: Offset = Offset.Unspecified
): Unit

Animate rotate by a ratio of degrees clockwise and suspend until its finished.

Parameters
degrees: Float

the degrees by which to rotate clockwise

animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)

AnimationSpec to be used for animation

centroid: Offset = Offset.Unspecified

the Offset around which the rotation should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.

suspend fun TransformableState.animateZoomBy(
    zoomFactor: Float,
    animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow),
    centroid: Offset = Offset.Unspecified
): Unit

Animate zoom by a ratio of zoomFactor over the current size and suspend until its finished.

Parameters
zoomFactor: Float

ratio over the current size by which to zoom. For example, if zoomFactor is 3f, zoom will be increased 3 fold from the current value.

animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)

AnimationSpec to be used for animation

centroid: Offset = Offset.Unspecified

the Offset around which the zoom should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.

suspend fun TransformableState.panBy(
    offset: Offset,
    centroid: Offset = Offset.Unspecified
): Unit

Pan without animation by a offset Offset in pixels and suspend until it's set.

Parameters
offset: Offset

offset in pixels by which to pan

centroid: Offset = Offset.Unspecified

the Offset around which the pan should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.

suspend fun TransformableState.rotateBy(
    degrees: Float,
    centroid: Offset = Offset.Unspecified
): Unit

Rotate without animation by a degrees degrees and suspend until it's set.

Parameters
degrees: Float

degrees by which to rotate

centroid: Offset = Offset.Unspecified

the Offset around which the rotation should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.

stopTransformation

suspend fun TransformableState.stopTransformation(
    terminationPriority: MutatePriority = MutatePriority.Default
): Unit

Stop and suspend until any ongoing TransformableState.transform with priority terminationPriority or lower is terminated.

Parameters
terminationPriority: MutatePriority = MutatePriority.Default

transformation that runs with this priority or lower will be stopped

suspend fun TransformableState.zoomBy(
    zoomFactor: Float,
    centroid: Offset = Offset.Unspecified
): Unit

Zoom without animation by a ratio of zoomFactor over the current size and suspend until it's set.

Parameters
zoomFactor: Float

ratio over the current size by which to zoom

centroid: Offset = Offset.Unspecified

the Offset around which the zoom should occur, if any. The default value is Offset.Unspecified, which leaves the behavior up to the implementation of the TransformableState.