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? |
|
A favorite icon’s tint, a card’s elevation, a progress value. |
Showing or hiding a composable entirely? |
|
A "no trails match your filter" empty state appearing/disappearing. |
Swapping what is shown for what, with both possibly overlapping mid-transition? |
|
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? |
|
Expanding a |
An animation that repeats forever with no target end state? |
|
A pulsing "recording" dot while a hike is being tracked. |
Animating imperatively — from a suspend function, possibly interrupted mid-flight by a gesture? |
|
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)? |
|
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? |
|
A |
The figure below turns this into a single decision flow; each terminal node names the API this page then covers in the matching section.
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 |
|---|---|
|
A fixed-duration animation interpolated by an |
|
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
|
|
Named values at specific fractions of the total duration ( |
|
Repeats another spec a fixed number of times, or forever (used by |
|
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.