Compose Animation and Gestures

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page covers Jetpack Compose’s own animation and gesture systems — separate APIs from, and largely superseding, the View system’s ViewPropertyAnimator/ObjectAnimator/MotionLayout/GestureDetector stack covered on Custom Views, Touch, Drawing and View Animation, which this page does not repeat. It targets this section’s baseline — Compose BOM 2026.09.00 (androidx.compose.animation 1.12.1, androidx.compose.foundation 1.12.1) — and builds on State, Recomposition and Side Effects in Compose, whose remember/LaunchedEffect/coroutine-scope material this page assumes. Three running TrailMate examples appear throughout: a favorite-heart animation on TrailCard, an expanding TrailCard that reveals more detail in place, and a pinch-to-zoom PhotoCaptureScreen preview.

Choosing an Animation API

Compose does not have one animation function — it has a small family, each suited to a different shape of problem, and picking the right one is mostly a matter of asking what is actually changing:

Question API Typical use

Animating a single value between two states?

animate*AsState (animateFloatAsState, animateColorAsState, animateDpAsState, …​)

A favorite icon’s tint, a card’s elevation, a progress value.

Showing or hiding a composable entirely?

AnimatedVisibility

A "no trails match your filter" empty state appearing/disappearing.

Swapping what is shown for what, with both possibly overlapping mid-transition?

AnimatedContent (or the simpler Crossfade for a pure fade)

Switching between a trail’s photo and a placeholder while it loads.

Coordinating several values that must all animate together for one logical state change?

updateTransition

Expanding a TrailCard: height, corner radius and content alpha all change together.

An animation that repeats forever with no target end state?

rememberInfiniteTransition

A pulsing "recording" dot while a hike is being tracked.

Animating imperatively — from a suspend function, possibly interrupted mid-flight by a gesture?

Animatable

A photo’s pan offset that must stop exactly where a drag released it, then animate to settle or snap back.

A layout changing size because its content changed (not because a state flag flipped)?

Modifier.animateContentSize

A `TrailCard’s description text expanding from one line to three.

An item in a lazy list changing position because items were added, removed or reordered?

Modifier.animateItem

A TrailListScreen LazyColumn reordering trails when the sort changes.

The figure below turns this into a single decision flow; each terminal node names the API this page then covers in the matching section.

flowchart TD Start(["What needs to animate?"]) --> Q1{"A single value
between two states?"} Q1 -->|"yes"| AnimateAsState["animate*AsState"] Q1 -->|"no"| Q2{"Show/hide a
whole composable?"} Q2 -->|"yes"| AnimatedVisibility["AnimatedVisibility"] Q2 -->|"no"| Q3{"Swap which content
is displayed?"} Q3 -->|"yes, cross-fade only"| Crossfade["Crossfade"] Q3 -->|"yes, sizes/enter-exit differ"| AnimatedContent["AnimatedContent"] Q3 -->|"no"| Q4{"Several values must
animate together for
one state change?"} Q4 -->|"yes"| UpdateTransition["updateTransition"] Q4 -->|"no"| Q5{"Repeats forever,
no end state?"} Q5 -->|"yes"| InfiniteTransition["rememberInfiniteTransition"] Q5 -->|"no"| Q6{"Driven imperatively
from a coroutine or
a gesture callback?"} Q6 -->|"yes"| Animatable["Animatable"] Q6 -->|"no"| Q7{"Layout resized by
changed content?"} Q7 -->|"yes"| AnimateContentSize["Modifier.animateContentSize"] Q7 -->|"no"| Q8{"Lazy list item
moved/added/removed?"} Q8 -->|"yes"| AnimateItem["Modifier.animateItem"] Q8 -->|"no"| Q9{"Same content moving
across two screens/states?"} Q9 -->|"yes"| SharedTransition["SharedTransitionLayout"] Q9 -->|"no"| PredictiveBack["PredictiveBackHandler /
system predictive back"]

See Android Developers — Animation in Compose and Android Developers — Choose an animation API for the same decision guide from the official source.

animate*AsState: a Single Animated Value

animate*AsState (animateFloatAsState, animateColorAsState, animateDpAsState, animateIntAsState, and others for Offset, Rect, Size) returns a State<T> that smoothly follows a target value every time that target changes, without the caller writing any explicit start/cancel logic — it is a remember`ed animation scoped to the composable’s lifetime, driven by a `LaunchedEffect internally:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
@Composable
fun FavoriteToggle(isFavorite: Boolean, onToggle: () -> Unit) {
    val tint by animateColorAsState(
        targetValue = if (isFavorite) Color.Red else MaterialTheme.colorScheme.onSurfaceVariant,
        animationSpec = tween(durationMillis = 200),
        label = "favoriteTint"
    )
    val scale by animateFloatAsState(
        targetValue = if (isFavorite) 1.2f else 1f,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
        label = "favoriteScale"
    )
    IconButton(onClick = onToggle) {
        Icon(
            imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
            contentDescription = stringResource(R.string.cd_favorite_toggle),
            tint = tint,
            // Lambda form: the animated value is read in the draw phase only, so each frame skips recomposition.
            modifier = Modifier.graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
        )
    }
}

Every time isFavorite flips, both targetValue expressions change, and each animate*AsState call automatically starts a new animation from wherever the value currently is toward the new target — if the user taps the heart again mid-animation, the color and scale reverse smoothly from their current position rather than snapping, because the underlying Animatable (below) always animates from its current value, never from the previous target. The label parameter names the animation in the Android Studio Animation Preview and Layout Inspector; it has no runtime effect but makes debugging multiple simultaneous animations far easier than leaving it out. Modifier.graphicsLayer is introduced properly near the end of this page; here it applies the animated scale without triggering a layout pass, since only the drawn pixels move. See Android Developers — Animate based on a value and Android Developers — animateColorAsState.

AnimatedVisibility

AnimatedVisibility animates a composable’s appearance and disappearance as a unit — entering with a combination of fade/slide/expand transitions and exiting with another, rather than the caller manually animating alpha and conditionally emitting the content:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
AnimatedVisibility(
    visible = uiState.trails.isEmpty() && !uiState.isLoading,
    enter = fadeIn(animationSpec = tween(300)) + expandVertically(),
    exit = fadeOut(animationSpec = tween(150)) + shrinkVertically()
) {
    EmptyTrailsMessage()
}

enter/exit compose freely with +: fadeIn() + slideInVertically { fullHeight → fullHeight / 2 } runs both transitions together over the same duration window unless each is given its own animationSpec. Inside an AnimatedVisibility block, this: AnimatedVisibilityScope also exposes Modifier.animateEnterExit, letting a child of the visible content run its own, independent enter/exit transition (an icon fading in later than its surrounding row, for example) instead of the whole subtree sharing one transition. See Android Developers — Use animation composables and modifiers.

AnimatedContent and Crossfade

AnimatedContent animates a transition between two arbitrary composable outputs of the same call site, keyed on a target state value — unlike AnimatedVisibility, which only shows or hides one fixed composable, and unlike manually swapping content in an if/else, which snaps instantly:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailPhoto.kt
AnimatedContent(
    targetState = photoState,
    transitionSpec = {
        (fadeIn(tween(300)) + scaleIn(initialScale = 0.92f))
            .togetherWith(fadeOut(tween(150)))
    },
    label = "trailPhoto"
) { state ->
    when (state) {
        is PhotoState.Loading -> CircularProgressIndicator()
        is PhotoState.Loaded -> AsyncImage(model = state.url, contentDescription = null)
        is PhotoState.Failed -> Icon(Icons.Outlined.BrokenImage, contentDescription = null)
    }
}

transitionSpec runs once per transition and returns a ContentTransform built from EnterTransition .togetherWith(ExitTransition); the lambda receives the previous and new targetState as AnimatedContentTransitionScope properties (initialState/targetState) when the transition needs to differ depending on which states are involved — for example, sliding left when moving forward through a sequence of steps and right when moving back. Crossfade is a simpler, older cousin restricted to a plain cross-fade with no other transition type, kept around mainly because its call site is shorter when a fade is genuinely all that is needed. See Android Developers — AnimatedContent.

updateTransition: Coordinating Several Values

updateTransition creates a Transition<S> from a single target state value, and every transition.animate* call hung off it animates in lockstep with the others — all reach their targets on the same overall transition, even when each uses a different AnimationSpec — which is what makes it the right tool once more than one value must change together for what is conceptually one state transition, such as expanding a card:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
@Composable
fun ExpandableTrailCard(trail: Trail) {
    var expanded by rememberSaveable { mutableStateOf(false) }
    val transition = updateTransition(targetState = expanded, label = "trailCardExpansion")

    val cornerRadius by transition.animateDp(label = "cornerRadius") { isExpanded ->
        if (isExpanded) 4.dp else 16.dp
    }
    val contentAlpha by transition.animateFloat(
        label = "descriptionAlpha",
        transitionSpec = { tween(durationMillis = if (targetState) 300 else 100) }
    ) { isExpanded ->
        if (isExpanded) 1f else 0f
    }

    Card(
        shape = RoundedCornerShape(cornerRadius),
        onClick = { expanded = !expanded }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(text = trail.name, style = MaterialTheme.typography.titleMedium)
            if (contentAlpha > 0f) {
                Text(
                    text = trail.description,
                    modifier = Modifier.graphicsLayer { alpha = contentAlpha }
                )
            }
        }
    }
}

Each transition.animate* call’s trailing lambda maps the shared targetState (here, a plain Boolean) to that particular value’s target for that state — cornerRadius shrinks while contentAlpha fades in, but both are driven by the same underlying expanded flip and Compose tracks them as one logical transition, which is what the Animation Preview in Android Studio visualizes as a single scrubbable timeline rather than several unrelated animations. transitionSpec on an individual animate* call can inspect targetState (through AnimatedContentTransitionScope-style access, here just targetState: Boolean directly) to use a different AnimationSpec for expanding versus collapsing, as the contentAlpha spec above does. See Android Developers — updateTransition.

rememberInfiniteTransition

rememberInfiniteTransition drives one or more values that repeat indefinitely with no final target state — the one case animate*AsState and updateTransition cannot express, since both are built around reaching and staying at a target:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/RecordHikeScreen.kt
@Composable
fun RecordingIndicator() {
    val infiniteTransition = rememberInfiniteTransition(label = "recordingPulse")
    val alpha by infiniteTransition.animateFloat(
        initialValue = 1f,
        targetValue = 0.3f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 600, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "pulseAlpha"
    )
    Box(
        modifier = Modifier
            .size(12.dp)
            .graphicsLayer { this.alpha = alpha } // deferred read: no recomposition per frame
            .background(Color.Red, CircleShape)
    )
}

infiniteRepeatable (as opposed to plain repeatable, which stops after a fixed iteration count) is what makes the animation run forever; RepeatMode.Reverse plays the spec forward then backward each cycle (a smooth pulse), while RepeatMode.Restart would jump back to initialValue at the start of each cycle instead. Because there is no target state to reach, an infinite transition must be stopped by removing the composable from composition entirely (leaving RecordHikeScreen) rather than by any parameter passed to it. See Android Developers — rememberInfiniteTransition.

Animatable: Imperative, Interruptible Animation

Animatable is the lowest-level animation primitive the APIs above are themselves built on: a single mutable value plus suspend functions (animateTo, snapTo, stop) to drive it from a coroutine, used whenever an animation must be started, interrupted or redirected imperatively — most commonly in response to a gesture, where the target is not known until the user’s finger moves or lifts. The photo-zoom example later in this page uses it to snap a pan offset back in bounds; a simpler case is settling a swiped TrailCard:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/SwipeableTrailCard.kt
@Composable
fun SwipeableTrailCard(trail: Trail, onDismiss: () -> Unit) {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    scope.launch { offsetX.snapTo(offsetX.value + delta) }
                },
                onDragStopped = {
                    scope.launch {
                        if (abs(offsetX.value) > dismissThresholdPx) {
                            offsetX.animateTo(targetValue = 1000f * offsetX.value.sign)
                            onDismiss()
                        } else {
                            offsetX.animateTo(targetValue = 0f, animationSpec = spring())
                        }
                    }
                }
            )
    ) {
        TrailCard(trail = trail, onFavoriteClick = {})
    }
}

Because offsetX is a single Animatable shared between the drag gesture (which calls snapTo, jumping instantly to each new value with no animation, since the finger’s own movement is the animation) and the release handling (which calls animateTo, which does animate, either flying off-screen to dismiss or springing back), a second drag started before the release animation finishes automatically interrupts it — Animatable always animates from its current value, so there is no separate "cancel the old animation first" step to get wrong. See Android Developers — Animatable.

Animation Specs

Every animation API above accepts an AnimationSpec<T> describing how a value moves from start to target, independent of what is animating:

Spec Behavior

tween(durationMillis, delayMillis, easing)

A fixed-duration animation interpolated by an Easing curve (LinearEasing, FastOutSlowInEasing, or a custom CubicBezierEasing). Predictable and easy to reason about, but never the default: it has to be passed explicitly, as the favorite-tint example above does.

spring(dampingRatio, stiffness)

A physically-simulated spring with no fixed duration — it settles naturally, and, critically, always animates smoothly from whatever value and velocity it currently has, which makes it the right choice whenever an animation can be interrupted mid-flight by a new target (a second favorite-toggle tap, a redirected drag). The Spring.DampingRatio* and Spring.Stiffness* constants (DampingRatioMediumBouncy, StiffnessLow, …​) cover most needs without hand-tuning raw numbers. spring() is the default animationSpec of animate*AsState, Animatable.animateTo, animateContentSize and animateItem, so an animation that passes no spec at all is a spring.

keyframes { …​ }

Named values at specific fractions of the total duration (0.3f at 200 using LinearEasing), for a motion that is not a simple straight interpolation between two points — overshooting past the target and settling back, for example.

repeatable(iterations, animation, repeatMode) / infiniteRepeatable(animation, repeatMode)

Repeats another spec a fixed number of times, or forever (used by rememberInfiniteTransition above).

snap(delayMillis)

No animation at all — an instant jump, after an optional delay; useful as one branch of a spec that only animates in one direction.

tween and keyframes are best for a fixed, designed motion (a specific number expected in a style guide); spring is best whenever the end of a previous animation might not be reached before a new one starts, because only a spring guarantees a smooth join with no visible position or velocity discontinuity. See Android Developers — Customize animations in Compose.

animateContentSize and animateItem

Modifier.animateContentSize() animates a composable’s own size whenever its measured size changes because its content changed — not because a state flag flipped a target size, but because, for example, a TrailCard’s description `Text grew from one line to three after new data arrived:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
Column(
    modifier = Modifier
        .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow))
) {
    Text(text = trail.name, style = MaterialTheme.typography.titleMedium)
    Text(text = if (showFullDescription) trail.description else trail.description.take(80))
}

This is a layout-size animation (the container smoothly resizes as its content’s measured size changes) and is therefore complementary to, not a replacement for, the value-based updateTransition example above, which animates properties like corner radius that do not themselves change measurement.

Modifier.animateItem(), applied inside a LazyColumn/LazyRow item’s content, animates that item whenever items are added, removed or reordered in the underlying list — the Compose equivalent of RecyclerView’s `ItemAnimator covered on RecyclerView and Lists. Since Compose 1.7 it takes three specs: fadeInSpec for an item appearing, placementSpec for an item moving to a new position, and fadeOutSpec for an item disappearing; passing null for any of them turns that part off:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
LazyColumn {
    items(items = uiState.trails, key = { it.id }) { trail ->
        TrailCard(
            trail = trail,
            onFavoriteClick = onFavoriteClick,
            modifier = Modifier.animateItem(
                fadeInSpec = tween(durationMillis = 250), // newly inserted trail fades in
                placementSpec = spring(stiffness = Spring.StiffnessLow), // reordered trail slides
                fadeOutSpec = tween(durationMillis = 150), // removed trail fades out
            )
        )
    }
}

animateItem requires the items(…​) call to pass a stable key (trail.id, not the list index) — without a key, Compose cannot tell that the item at a new position is "the same" TrailCard that moved rather than a different one that happened to land there, and the animation either does not run or animates the wrong item. See Android Developers — animateContentSize and Android Developers — Lists and grids, item animations.

Shared-Element Transitions

A shared-element transition animates one visual element — here, a trail’s photo — so it appears to move and resize continuously as the user navigates from TrailListScreen to TrailDetailScreen, rather than the photo disappearing on one screen and a different-looking one popping in on the next. Compose implements this with SharedTransitionLayout, wrapping the composables both the list and detail screens' matching elements share:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
@Composable
fun TrailMateApp() {
    SharedTransitionLayout {
        AnimatedContent(targetState = currentRoute, label = "trailMateNav") { route ->
            when (route) {
                is TrailList -> TrailListScreen(
                    trails = trails,
                    sharedTransitionScope = this@SharedTransitionLayout,
                    animatedContentScope = this@AnimatedContent
                )
                is TrailDetail -> TrailDetailScreen(
                    trailId = route.trailId,
                    sharedTransitionScope = this@SharedTransitionLayout,
                    animatedContentScope = this@AnimatedContent
                )
            }
        }
    }
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailPhoto.kt
context(sharedTransitionScope: SharedTransitionScope, animatedContentScope: AnimatedContentScope)
@Composable
fun TrailPhoto(trail: Trail, modifier: Modifier = Modifier) {
    with(sharedTransitionScope) {
        AsyncImage(
            model = trail.photoUrl,
            contentDescription = null,
            modifier = modifier.sharedElement(
                sharedContentState = rememberSharedContentState(key = "trail-photo-${trail.id}"),
                animatedVisibilityScope = animatedContentScope
            )
        )
    }
}

Both the list-screen photo and the detail-screen photo call Modifier.sharedElement with the same rememberSharedContentState(key = …​) value — the shared key, not the composable identity, is what tells Compose these two calls (on two different screens, in two different composition subtrees) are conceptually "the same" element to morph between. SharedTransitionScope/AnimatedContentScope must be threaded down to wherever the shared element actually lives, which is why TrailPhoto above declares them as context parameters (Kotlin 2.2+ context parameters, see Kotlin for Android) rather than reaching for a CompositionLocal. The core shared-element API (SharedTransitionLayout, Modifier.sharedElement, Modifier.sharedBounds) has been stable since Compose 1.10, so none of the code above needs an @OptIn(ExperimentalSharedTransitionApi::class); older samples that still carry the opt-in predate that. See Android Developers — Shared element transitions.

Predictive-Back Animations

Since Android 13, the system can play a live preview of the previous screen while the user’s back gesture is still in progress — letting them see, and cancel, the navigation before committing to it, rather than the transition firing instantly on gesture completion the way onBackPressed() always did. Because targeting API 36 makes this the default back behavior and stops dispatching the legacy onBackPressed()/KEYCODE_BACK path entirely (see Activities, the Activity Lifecycle and Configuration Changes), TrailMate’s own custom back animations (rather than the ones Navigation 3 already animates automatically for route transitions) are written against PredictiveBackHandler, a suspend-based composable API that exposes the gesture’s progress as a Flow:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureScreen.kt
@Composable
fun PhotoPreviewOverlay(onDismissed: () -> Unit, content: @Composable (scale: Float) -> Unit) {
    var scale by remember { mutableFloatStateOf(1f) }
    PredictiveBackHandler { progress: Flow<BackEventCompat> ->
        try {
            progress.collect { backEvent ->
                scale = 1f - backEvent.progress * 0.15f // shrink slightly as the gesture progresses
            }
            onDismissed() // gesture completed
        } catch (e: CancellationException) {
            scale = 1f // gesture was cancelled; snap back
        }
    }
    content(scale)
}

PredictiveBackHandler’s lambda receives a `Flow<BackEventCompat> (each event carrying progress: Float, touchX/touchY, and swipeEdge) that emits for as long as the gesture is held, and the surrounding try/catch distinguishes the gesture completing normally (the collect call returns) from the user releasing back toward the original screen, which cancels the coroutine collecting the flow — exactly the shape of API Animatable.animateTo being interrupted, applied to a system-driven gesture instead of a touch gesture the app reads directly. See Android Developers — Support predictive back gestures and Android Developers — Handle the predictive back gesture.

Gestures

Compose’s gesture APIs range from a single tap through fully custom multi-touch handling, and, like the animation APIs above, the right layer depends on how much of the built-in behavior (ripple feedback, click semantics for accessibility, scroll physics) the gesture needs.

clickable and combinedClickable

Modifier.clickable is the simplest gesture modifier: a single tap, with a ripple indication and correct accessibility semantics (Role.Button, click actions exposed to TalkBack) applied automatically — the reason IconButton/Card(onClick = …​) above are preferred over a bare Modifier.clickable only when a dedicated component already exists, and clickable is reached for directly whenever no such component fits:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
Row(
    modifier = Modifier.clickable(
        onClickLabel = stringResource(R.string.cd_open_trail_detail),
        role = Role.Button
    ) { onTrailClick(trail.id) }
)

Modifier.combinedClickable adds onLongClick and onDoubleClick alongside the same tap handling — TrailMate uses it for a long-press context menu on a TrailCard (rename, delete, share) without giving up the ripple and accessibility behavior clickable already provides:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
Modifier.combinedClickable(
    onClick = { onTrailClick(trail.id) },
    onLongClick = { onShowTrailOptions(trail.id) }
)

draggable, AnchoredDraggable and transformable

Modifier.draggable reports a single-axis drag delta through a DraggableState (used by SwipeableTrailCard above); it says nothing about where the drag should end up, leaving that entirely to the caller’s onDragStopped logic.

AnchoredDraggable (via Modifier.anchoredDraggable and an AnchoredDraggableState) adds exactly that: a fixed set of named target positions ("anchors") the drag always settles into on release, which is the lower-level primitive BottomSheet/SwipeToDismissBox-style components are themselves built on — reached for directly whenever a similar "snap to one of a few positions" swipe is needed without an off-the-shelf Material component to match:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
val density = LocalDensity.current
val anchoredState = remember {
    AnchoredDraggableState(initialValue = SheetValue.Collapsed)
}
val sheetHeightPx = with(density) { 240.dp.toPx() }

LaunchedEffect(sheetHeightPx) {
    anchoredState.updateAnchors(
        DraggableAnchors {
            SheetValue.Collapsed at 0f
            SheetValue.Expanded at -sheetHeightPx
        }
    )
}

Box(
    modifier = Modifier
        .offset { IntOffset(x = 0, y = anchoredState.requireOffset().roundToInt()) }
        .anchoredDraggable(state = anchoredState, orientation = Orientation.Vertical)
)

Modifier.transformable reports pan, zoom (scale) and rotation together through a TransformableState, built for exactly the pinch-to-zoom-and-pan gesture the photo-zoom example below needs, and is generally preferred over hand-rolling the same math with pointerInput/detectTransformGestures whenever plain pan/zoom/rotate is all that is required.

scrollable and nestedScroll

Modifier.scrollable is the lower-level primitive LazyColumn, Modifier.verticalScroll and the other built-in scrolling containers are themselves built on — a single-axis scroll delta reported through a ScrollableState, reached for directly only when building a custom scrolling container none of the higher-level ones fit. Modifier.nestedScroll lets a parent and a child scrollable container cooperate over one gesture — a NestedScrollConnection on the parent can consume part of a scroll delta before the child sees it (a collapsing top app bar consuming upward scroll to shrink itself first, then handing the remainder to the LazyColumn beneath it), which is the Compose equivalent of the View system’s CoordinatorLayout/ AppBarLayout scrolling behaviors covered on Custom Views, Touch, Drawing and View Animation.

pointerInput and detect*Gestures

Modifier.pointerInput(key1, …​) is the lowest-level entry point into Compose’s gesture system: a suspend block, restarted whenever any of its keys change, that reads raw pointer events directly or, far more commonly, calls one of the detect*Gestures helper functions built on top of it — detectTapGestures, detectDragGestures, detectTransformGestures, detectVerticalDragGestures. It is reached for only when none of clickable/draggable/transformable above already expresses the needed gesture, most often because two gesture types must be recognized together or disambiguated against each other:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoZoomView.kt
Modifier.pointerInput(Unit) {
    detectTapGestures(
        onDoubleTap = { tapOffset ->
            scope.launch {
                if (scale.value > 1f) {
                    scale.animateTo(1f)
                    offset.animateTo(Offset.Zero)
                } else {
                    scale.animateTo(2.5f)
                }
            }
        }
    )
}

The key1/key2/…​ parameters of pointerInput matter for correctness, not just performance: a lambda that captures a value from composition (scale, offset above) closes over whatever that value was when the suspend block started, and pointerInput only restarts (recreating that closure with the current value) when one of its keys changes — omitting a key that the block actually depends on silently keeps using a stale captured value for the lifetime of the composable, a subtle bug the compiler cannot catch. See Android Developers — Handle gestures and Android Developers — Detect drag, swipe and fling gestures and Android Developers — androidx.compose.foundation.gestures.

graphicsLayer and drawBehind

Modifier.graphicsLayer { …​ } applies transformations — scaleX/scaleY, rotationZ, alpha, translationX/translationY, a clip shape — to a composable’s already-laid-out content, on the drawing phase only, with no re-measurement or re-layout involved; every animated scale/alpha example on this page (FavoriteToggle, ExpandableTrailCard, RecordingIndicator) uses it for exactly that reason — it is the cheapest way to animate a purely visual property every frame, and is why it, rather than a Modifier.scale/ Modifier.alpha call recomputed from a plain State read outside graphicsLayer, is what the Compose performance guidance recommends for a value that changes on every frame of an animation. Modifier.drawBehind draws directly onto the composable’s Canvas beneath its normal content (an underline, a custom background shape, a chart) using the same Canvas/Paint/Path drawing model introduced for ElevationProfileView on Custom Views, Touch, Drawing and View Animation. Both are covered in full, alongside the rest of Compose’s Canvas composable and DrawScope APIs, on Graphics: Canvas, OpenGL ES and GLTextureView; this section only introduces them far enough to explain why the animations above reach for graphicsLayer specifically. See Android Developers — Graphics in Compose and Android Developers — Modifier.graphicsLayer.

Photo Zoom: Putting Gestures and Animatable Together

PhotoCaptureScreen’s full-screen photo preview combines `transformable (pan and zoom), Animatable (to snap back in bounds or reset on double-tap) and graphicsLayer (to apply the animated transform) into one composable:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoZoomView.kt
@Composable
fun PhotoZoomView(photoUrl: String, modifier: Modifier = Modifier) {
    val scale = remember { Animatable(1f) }
    val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
    val scope = rememberCoroutineScope()

    val transformableState = rememberTransformableState { zoomChange, panChange, _ ->
        scope.launch {
            val newScale = (scale.value * zoomChange).coerceIn(1f, 4f)
            scale.snapTo(newScale)
            offset.snapTo(offset.value + panChange)
        }
    }

    AsyncImage(
        model = photoUrl,
        contentDescription = stringResource(R.string.cd_trail_photo_preview),
        modifier = modifier
            .graphicsLayer {
                scaleX = scale.value
                scaleY = scale.value
                translationX = offset.value.x
                translationY = offset.value.y
            }
            .transformable(state = transformableState)
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = {
                        scope.launch {
                            scale.animateTo(if (scale.value > 1f) 1f else 2.5f)
                            offset.animateTo(Offset.Zero)
                        }
                    }
                )
            }
    )
}

transformable reports live gesture deltas, so its callback uses snapTo (the transform must track the finger exactly, with no animation lag); the double-tap handler uses animateTo instead, because resetting or zooming in on a tap should animate smoothly rather than snap. Both Animatable instances are shared between the two gesture handlers the same way offsetX was shared in SwipeableTrailCard earlier, so a double-tap that lands mid-pinch correctly interrupts and redirects whatever transform was already in progress instead of fighting it.