Compose Animation & Gestures
animate*AsState, Transition, AnimatedContent, Animatable, pointerInput, drag, nestedScroll, Canvas
- 01
A progress bar animated with
animateFloatAsStatehas to jump straight back to 0 on retry, and instead it slides back. What do you switch to?Easyanimate*AsStatehides its value behind a declarative API that can only ever animate towards a new target, so an instant reset needsAnimatable, which hands you the value and imperative control over it.// ❌ animate*AsState owns the value; there is no way to reset it without motion @Composable fun UploadBarWrong(progress: Float) { val shown by animateFloatAsState(progress, label = "progress") LinearProgressIndicator(progress = { shown }) // retry sets progress = 0f and the bar slides all the way back … - 02
AnimatedContent(targetState = count) { Text("Total: $count") }swaps the number with no visible transition at all. What is wrong?EasyThe content lambda ignores its own parameter and reads the captured
count, so the outgoing content recomposes to the new number as well and you are cross-fading a value with an identical copy of itself.// ❌ the lambda ignores its parameter and reads the captured count, so the leaving // copy re-renders with the new number and the crossfade is invisible AnimatedContent(targetState = count, label = "total") { Text("Total: $count") } … - 03
Sorting a
LazyColumnmakes every row teleport to its new position and a freshly inserted row pops in. What turns that into motion?MediumModifier.animateItem()on the item's root animates placement, appearance and disappearance — but only for items with stablekeys, because without one an item is identified by its index and a reorder is indistinguishable from a content change.@Composable fun TaskList(tasks: List<Task>, onToggle: (String) -> Unit) { LazyColumn { items( items = tasks, key = { it.id }, // stable, unique, saveable — this is the mechanism … - 04
Tapping a list row should make its cover image fly into the detail screen, but it only cross-fades. What does a shared element need from both screens?
MediumBoth composables must sit under one
SharedTransitionLayout, carryModifier.sharedElementwith the same key, and each be inside anAnimatedVisibilityScope— the transition is what tells the layout when those two nodes are one thing in flight.@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun App(nav: NavHostController) { SharedTransitionLayout { // OUTSIDE the NavHost NavHost(nav, startDestination = "list") { composable("list") { … - 05
A designer specs "300ms ease-out", but the motion must also survive being interrupted halfway — tween, spring or keyframes, and what is
visibilityThresholdprotecting?MediumTake the spring unless the exact curve is the point, because a spring carries the current velocity into a retarget while a
tweenrestarts its curve from wherever the value happened to be and visibly stutters.// a curve the designer specified — fixed duration, exact easing val alpha by animateFloatAsState( targetValue = if (visible) 1f else 0f, animationSpec = tween(durationMillis = 300, easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)), label = "alpha", ) … - 06
Your draggable card follows the finger perfectly but stops dead the instant it is released. What does the
Animatableneed during the drag and after it?MediumDuring the drag you
snapToeach new value so nothing animates, and on release you feed the tracked velocity intoanimateDecay— or intoanimateTo(target, initialVelocity = v)— so the motion continues from the speed the finger left behind.@Composable fun CoastingCard(modifier: Modifier = Modifier) { val offsetX = remember { Animatable(0f) } Box( modifier … - 07
A row's
Modifier.pointerInput(Unit)block keeps calling the previousonSelectlambda after the callback changes. What is the key doing, and what is the fix?MediumThe
pointerInputblock is a coroutine launched once and cancelled and restarted only when its keys change, so withUnitas the key it captured the firstonSelectand holds that instance for the node's whole life.// ❌ pointerInput(Unit) captures onSelect once and never sees a newer one Modifier.pointerInput(Unit) { detectTapGestures(onLongPress = { onSelect(item.id) }) } // ❌ keying on the lambda restarts the coroutine whenever its identity changes — … - 08
You swap
Modifier.clickableforpointerInput { detectTapGestures(...) }to add a double tap, and QA files three bugs. Which three?MediumNo ripple, nothing for TalkBack to activate, and no keyboard or D-pad focus —
clickableis not a tap listener, it is a tap listener plus indication, semantics and focus, anddetectTapGesturesis only the listener.// ❌ a tap listener and nothing else: no ripple, no semantics, not focusable Box( Modifier.pointerInput(Unit) { detectTapGestures(onTap = { open() }, onDoubleTap = { like() }) }, ) { PostBody() } … - 09
Your swipe-to-delete row snaps back instead of staying open, and requireOffset() throws on the first frame — what is the AnchoredDraggable setup missing?
MediumAnchoredDraggableStatehas nowhere to settle until it is given anchors, and untilupdateAnchorshas run its offset isFloat.NaN— both symptoms are the same missing step.enum class Swipe { Settled, Dismissed } @Composable fun SwipeToDeleteRow(item: Item, onDelete: () -> Unit) { val density = LocalDensity.current val decay = rememberSplineBasedDecay<Float>() … - 10
Your collapsing header collapses correctly, but on the way back it expands before the list has scrolled to the top — what is wrong with the NestedScrollConnection?
MediumThe header is consuming the downward delta in
onPreScroll, where it gets first refusal; expanding belongs inonPostScroll, on what the list did not use.class CollapsingHeaderConnection(private val maxOffsetPx: Float) : NestedScrollConnection { var offset by mutableFloatStateOf(0f) // -maxOffsetPx..0f private set // going up: the header shrinks BEFORE the list scrolls … - 11
Adding a Modifier.pointerInput drag handle to a row killed the LazyColumn's scrolling — how does Compose decide which gesture wins?
MediumThere is no arena and no negotiation: the first node to call
consume()on a pointer change owns the gesture, and the order is fixed by the three passes every event makes through the modifier chain.// wrong: claims the gesture the moment slop is crossed in ANY direction, // so the LazyColumn underneath never sees a vertical drag Modifier.pointerInput(Unit) { detectDragGestures { change, drag -> change.consume() offsetX += drag.x … - 12
Fading a stack of overlapping shapes through graphicsLayer makes the seams between them show through — what is the layer doing, and which knob fixes it?
MediumAlpha is being applied to each drawing command separately instead of to the composited group, and
compositingStrategyis what selects between those two behaviours.@Composable fun FadingStack(fade: Float) { // wrong: alpha per drawing instruction, so the circles show through each other Box( Modifier.graphicsLayer { alpha = fade … - 13
Your gauge rebuilds its Path and its gradient Brush on every frame inside drawBehind — what does drawWithCache change, and where does the drawing itself go?
HarddrawWithCachesplits the modifier in two: the outer block re-runs only when the size changes or a state it read changes, and theonDraw*block it returns runs every frame — so thePathand theBrushare built once and reused.@Composable fun Gauge(progress: Float, modifier: Modifier = Modifier) { val sweep by animateFloatAsState(progress * 270f, label = "sweep") Box( modifier … - 14
Design wants a frosted-glass panel and an animated shader border — what actually exists at each API level, and what do you ship to a device that has neither?
HardModifier.bluris real only from Android 12 (API 31) andRuntimeShaderwith AGSL only from Android 13 (API 33), so both have to degrade to something you drew yourself.@Composable fun FrostedPanel(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { val canBlur = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // API 31 Box(modifier.clip(RoundedCornerShape(24.dp))) { Wallpaper( … - 15
Switching your gallery from one column to a two-column grid makes every card jump to its new place in a single frame — how do you animate a layout change like that?
HardWrap the changing region in a
LookaheadScope: it measures and places the subtree twice per frame — once for where everything is going, once for where you actually draw it — so each node can animate from its current bounds towards a target it can see.@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun Gallery(items: List<Item>, grid: Boolean) { LookaheadScope { FlowRow(maxItemsInEachRow = if (grid) 2 else 1) { items.forEach { item -> … - 16
The user turned animations off in Accessibility settings, yet your card still slides in at full length — what does Compose honour by itself, and what do you have to gate?
HardCompose reads the system animator duration scale through a
MotionDurationScaleelement in the recomposer's coroutine context, so animations started from the composition jump straight to their target at scale 0 — one started on a scope you built yourself carries no such element and ignores the setting entirely.// wrong: an animation driven from a scope that carries no MotionDurationScale. // It plays at full length even when the user has turned animations off. class CardViewModel : ViewModel() { val reveal = Animatable(0f) fun onOpen() = viewModelScope.launch { reveal.animateTo(1f, tween(400)) } } …