Add Modifier.animateContentSize() to the container: whenever its measured size changes, it animates from the old size to the new one instead of jumping.
Order matters: modifiers before it (background, clip) follow the animated size; put it before the padding or size you want included in what it animates.
Only the container's size animates: new content inside it appears instantly. Wrap that content in AnimatedVisibility if it should also fade in.
The spec is a parameter: animateContentSize(animationSpec = spring(...)), plus a finishedListener if you need to know when it ends.
AndroidMiddle
Compose Animation & Gestures
animate*AsState, Transition, AnimatedContent, Animatable, pointerInput, drag, nestedScroll, Canvas
Questions closed
0 / 24
- 0Review
Questions
Wrap it in AnimatedVisibility(visible = ...); it composes the content when visible is true, plays an EnterTransition on the way in and an ExitTransition on the way out, and removes the content from the composition when the exit ends.
**Combine effects with +**: fadeIn() + slideInVertically() for enter, fadeOut() + shrinkVertically() for exit; each has its own animationSpec.
Children can differ: Modifier.animateEnterExit on a child inside the content gives it its own enter and exit.
First composition: to animate on the first frame, pass MutableTransitionState(false).apply { targetState = true } instead of a boolean.
Siblings in a Column or Row animate their position as the item grows or shrinks.
Swapping two contents is a different tool: AnimatedContent or the simpler Crossfade.
Not the same as alpha: an alpha of 0 keeps the node laid out and touchable, while AnimatedVisibility removes it.
Create a rememberInfiniteTransition() and animate values on it with animateFloat or animateColor and an infiniteRepeatable spec; it starts when it enters the composition and stops when it leaves.
**RepeatMode.Reverse plays back and forth (a pulse); RepeatMode.Restart jumps back to the start (a spinner, a shimmer sweep).
There is no stop()**: to end the loop, stop composing it, for example by showing the loaded content instead of the placeholder. For a fixed number of runs use repeatable(iterations = 3, ...).
**Read the value in graphicsLayer { } or a draw lambda so each frame only redraws instead of recomposing.
Animations off**: with "Remove animations" an infinite transition stops at its targetValue, which may be an awkward frame; check ValueAnimator.areAnimatorsEnabled() and show a static placeholder instead.
The content lambda ignores its own parameter and reads the captured count, so the leaving copy recomposes with the new value too. Render the state the lambda receives: { target -> Text("Total: $target") }.
Two copies at once: while the transition runs, AnimatedContent keeps the leaving and the arriving content composed side by side, and each copy gets the state it was created for as the lambda parameter.
**transitionSpec** returns a ContentTransform: the enter of the new content togetherWith the exit of the old one (togetherWith replaced the deprecated with). initialState and targetState are available inside, so you can slide up when the number grows and down when it shrinks.
Use clickable for a plain tap and combinedClickable when you also need a long press or double tap; drop to detectTapGestures only when the tap is not a click, for example when the tap position on a chart matters.
**clickable** is more than a listener: it draws the ripple through an InteractionSource, publishes the onClick semantics action that TalkBack invokes, makes the node focusable for keyboard and D-pad, and handles enabled = false.
**combinedClickable(onClick, onLongClick, onDoubleClick)** keeps all of that. With onDoubleClick set, onClick fires only after the double-tap timeout, because it has to wait for a possible second tap.
**detectTapGestures** gives raw callbacks with an Offset and nothing else: no ripple, no semantics, not focusable. If a user can activate it, add Modifier.semantics { onClick(...) } yourself.
Clip before clickable, or the ripple draws as a rectangle over a rounded card.
All three give you a DrawScope with drawCircle, drawLine, drawArc, drawPath and friends: Canvas is a standalone empty element, drawBehind paints behind an existing composable, and drawWithContent lets you paint over its children.
**Canvas(modifier) { }** is just a Spacer with drawBehind. It has no size of its own, so give it size or fillMaxWidth, or it draws nothing.
**drawWithContent { drawContent(); ... } decides where the children land in the drawing order: a badge, a scrim or a highlight on top.
Units are pixels**: DrawScope is a Density, so 4.dp.toPx() works inside it, and size and center describe the area.
Drawing is the last phase: a state read inside the draw lambda only triggers a redraw, with no recomposition or layout, which makes it cheap for animated progress.
animate*AsState animates one value towards a target; updateTransition drives several values from one state so they stay in sync; Animatable is an imperative, coroutine-driven value for when a gesture or an event must control the animation.
**animate*AsState** is fire and forget: change the target and it retargets mid-flight. It has no snapTo, stop or velocity input, so a progress bar that must jump back to 0 on retry slides back instead.
**updateTransition(targetState)**: animateDp, animateColor, animateFloat run off one clock: they start, retarget and finish together, and Android Studio's Animation Preview can scrub them. AnimatedVisibility and AnimatedContent are built on it.
**Animatable(0f)**: held in remember and driven from LaunchedEffect or a gesture coroutine: animateTo, snapTo (no motion), animateDecay (fling), stop, updateBounds. Every call goes through a MutatorMutex, so a new one cancels the running one instead of fighting it.
Rule of thumb: one state to one value: animate*AsState; one state to many values: updateTransition; a gesture, a sequence or an instant reset: Animatable.
With the default spring, all three carry the current velocity into a retarget, which is why rapid toggling looks continuous.
A runs them one after another (alpha first, then offsetY) and takes 600 ms, because animateTo is a suspend function that returns only when its animation ends. B runs them together in 300 ms, because each launch starts its own child coroutine.
That is the whole chaining model: a sequence is suspend calls in a row; parallel is launch. To continue only after both parallel parts finish, wrap them in coroutineScope { launch { ... }; launch { ... } } and put the next animateTo after it.
Stagger: delay(80) between launches, or tween(durationMillis = 300, delayMillis = 80) on the second animation.
Cancellation: if the effect's key changes or the composable leaves, the coroutine is cancelled, animateTo throws CancellationException and the value stays where it stopped. A second animateTo on the same Animatable cancels the first.
Declarative alternatives: updateTransition runs several values in parallel from one state, and keyframes or delayMillis inside its specs give a staggered sequence without coroutines.
Animatable(0f) is created in the composable body without remember, so every recomposition builds a fresh instance at 0; the restarted LaunchedEffect animates that new one from zero and the old value is lost.
Why it happens: the body of a composable runs again on every recomposition, and a plain val is rebuilt each time. Only remember keeps an object in the slot table between recompositions.
The quieter symptom: if the function recomposes for another reason (for example, the parent passes a new modifier) while progress stays the same, the effect does not restart: it keeps driving the old instance while drawBehind reads the new one, so the bar shows empty.
How the fix works: remember { Animatable(0f) } keeps one instance; a new progress restarts the effect, which cancels the running animateTo, and the next animateTo continues from the current value (and velocity too, with a spring spec).
Simpler option: for a single target value, animateFloatAsState(progress) does the remember and the effect for you.
Catch it next time: the UnrememberedAnimatable lint check that ships with animation-core flags this line; its sibling UnrememberedMutableState flags mutableStateOf without remember.
Use spring for motion that can be interrupted or follows a gesture, because it carries the current velocity into a new target; tween when a designer specified an exact duration and easing; keyframes for multi-step motion such as an overshoot.
**spring(dampingRatio, stiffness)**: physics, no duration. It is the default for animate*AsState and Animatable. Spring.DampingRatioMediumBouncy adds bounce, Spring.StiffnessLow slows it down.
**tween(durationMillis, delayMillis, easing)**: fixed time and curve: FastOutSlowInEasing, LinearEasing, or a CubicBezierEasing copied from the design tool. Interrupted halfway, it restarts from the current value with no velocity, which shows as a kink; fine for alpha and colour.
**keyframes { durationMillis = 375; 1.08f at 250 }: values at timestamps, each segment with its own easing.
visibilityThreshold**: a spring approaches its target asymptotically and never quite arrives, so it needs a "close enough" value at which it snaps and finishes. Typed APIs pass a sensible one (Dp.VisibilityThreshold is 0.1 dp), but a bare Animatable(0f) uses 0.01f; animate pixels with it and you spend frames on movement nobody can see. Pass Animatable(0f, visibilityThreshold = 0.5f).
Add Modifier.animateItem() to each item's root and give the items stable keys; insertions then fade in, removals fade out and reordered items slide to their new positions.
**animateItem(fadeInSpec, placementSpec, fadeOutSpec)** replaced animateItemPlacement(), which handled moves only, in Compose 1.7. Pass null for a spec to switch that part off. It works the same in LazyRow and lazy grids.
Keys are the mechanism: without a key, items are identified by index, so a sort looks like content changing in place and nothing moves. The key must be unique and stable: key = { it.hashCode() } on a data class whose fields change turns every edit into a removal plus an insertion.
Put it on the item's outermost layout: on a nested Text nothing animates, because the node that moves is the item root.
It animates position and presence only: an item whose own content grows still needs animateContentSize().
Wrap both screens in one SharedTransitionLayout, mark the matching elements with Modifier.sharedElement using the same key, and give each the AnimatedVisibilityScope of the transition that swaps the screens; in a NavHost that is the receiver of the composable { } lambda.
**SharedTransitionLayout** goes outside the NavHost and provides SharedTransitionScope, where sharedElement and rememberSharedContentState live.
**sharedElement is for the same content on both screens (a cover image). sharedBounds is for different content sharing one box (a card that becomes a screen) and adds enter and exit for what is inside.
No transition, no shared element**: the scope can come from AnimatedContent, AnimatedVisibility or a NavHost destination. With Navigation Compose 2.8+ the element also follows the predictive back gesture.
It flies in an overlay above other content, so it escapes clipping parents but can draw over your top bar; Modifier.renderInSharedTransitionScopeOverlay() on the bar keeps it on top.
Typical bugs: keys built from an index or duplicated among visible items, and the screen composed outside the NavHost with no scope to pass. The API sits behind @ExperimentalSharedTransitionApi, so keep the opt-in in one place.
Compose scales its animations by the system animator duration scale, so at 0 an animation started from the composition jumps straight to its end; you still have to handle looping motion and animations driven from outside the composition.
Where the setting lives: "Remove animations" in Accessibility and "Animator duration scale" in developer options both write Settings.Global.ANIMATOR_DURATION_SCALE.
How it reaches your code: the window's recomposer puts a MotionDurationScale into its coroutine context. animate*AsState, transitions, AnimatedVisibility, and Animatable calls from LaunchedEffect or rememberCoroutineScope() inherit it.
Loops freeze on an arbitrary frame: at scale 0 an InfiniteTransition jumps to its targetValue and stops, which may be half a shimmer. Check ValueAnimator.areAnimatorsEnabled() and draw a deliberate still state. A hand-written while (true) { anim.animateTo(...) } returns instantly at scale 0 and spins, so guard it.
Scopes you build yourself: viewModelScope or a custom CoroutineScope do not carry that context. Keep animation state in the UI layer.
Large motion: parallax, big zooms and autoplay are a vestibular problem, not taste; gate them on the same flag, read once and provided through a CompositionLocal.
Tests run on a virtual clock that ignores the setting: set composeTestRule.mainClock.autoAdvance = false and step with advanceTimeBy() to assert mid-animation.
graphicsLayer draws the composable into its own render layer with transform properties (alpha, scaleX/Y, rotationZ, translationX/Y, shadowElevation, clip); changing them redraws the layer without recomposing or re-measuring the content.
Use the lambda form: graphicsLayer { alpha = a } reads state in the draw phase, so a 60 fps animation costs a redraw per frame. The parameter form graphicsLayer(alpha = a), like Modifier.alpha(a), reads it in composition and recomposes every frame.
Layout does not move: a translated or scaled layer keeps its layout slot, so siblings stay where they are. Use it for visual motion, not to change the layout.
Pivot and 3D: transformOrigin sets the pivot; rotationX/Y with cameraDistance give a card flip.
**compositingStrategy**: Auto fades overlapping children as one group when alpha is below 1; pick ModulateAlpha or Offscreen only after measuring.
**alpha = 0f is not gone**: the node is still laid out and still receives touches. To remove it, stop composing it.
pointerInput(key) { } runs a suspending block where you await pointer events, either through helpers like detectDragGestures or by hand with awaitEachGesture and awaitFirstDown; the block restarts only when a key changes.
Helpers first: detectTapGestures, detectDragGestures and its horizontal and vertical variants, detectDragGesturesAfterLongPress, detectTransformGestures. They are thin wrappers over the loop below.
The low-level loop: awaitEachGesture { } runs once per gesture and waits for all pointers to lift before the next one. Inside, awaitFirstDown(), awaitPointerEvent(), the touch-slop helpers and drag(pointerId) { } read the events.
**change.consume() marks a change as handled; the built-in detectors further out skip consumed changes, which is how a child claims a gesture.
Keys**: Unit means the coroutine never restarts, so a callback captured on the first run stays forever. Read callbacks through rememberUpdatedState; keying on the lambda itself restarts the block, cancelling a gesture in progress, whenever its identity changes.
Modifier order: padding before pointerInput leaves the padding untouchable; after it, the padding is included.
Prefer draggable, scrollable and transformable when they fit: they already handle slop, velocity and fling.
Drag deltas arrive in pixels, but offsetX.dp treats the pixel sum as dp, so the chip moves density times farther than the finger (2.75× on a typical 440 dpi phone); only on a density-1.0 screen does it look right.
Why it happens: dragAmount from detectHorizontalDragGestures is in px, while Modifier.offset(x: Dp) multiplies its argument by the screen density at layout. Both are plain Floats before .dp, so the mix-up compiles without a warning.
How the fix works: the lambda overload offset { IntOffset(...) } takes pixels, so state and layout use the same unit. Keep gesture state in px and convert with LocalDensity only where a Dp API needs it.
Side benefit: the lambda is read in the layout phase, so a drag no longer recomposes DraggableChip on every pointer event; only placement reruns.
Catch it next time: test drags on a high-density device, not a density-1.0 emulator, and name pixel values with a Px suffix; a UI test that swipes a known distance with performTouchInput and checks the new position catches it too.
The first node to consume() a pointer change owns the gesture. detectDragGestures claims a drag in any direction once touch slop is crossed, so it steals vertical drags from the list; an orientation-locked detector fixes it.
Three passes: every event goes through Initial (parent to child), Main (child to parent, where the detectors run) and Final (parent to child again). In Main the innermost node gets the first claim.
The fix: detectHorizontalDragGestures or Modifier.draggable(orientation = Orientation.Horizontal) claim only horizontal slop, so vertical movement falls through to the list. That is also why a HorizontalPager inside a vertical scroll needs no special handling.
A parent intercepting: listen on PointerEventPass.Initial and consume there, before the children see the event; a locked overlay that swallows touches works this way.
Losing gracefully: a detector that sees a consumed change cancels itself: onTap never fires after the list has taken the touch for a scroll.
Same axis: two vertical scrollables do not fight over pointers. The inner one scrolls first and hands what it did not use outwards through nested scroll.
Use Modifier.transformable with rememberTransformableState, or detectTransformGestures inside pointerInput; both report zoom, pan and rotation deltas per event, which you accumulate in state and apply through graphicsLayer.
Accumulate zoom multiplicatively: scale = (scale * zoomChange).coerceIn(1f, 5f); adding the deltas drifts.
**Apply in graphicsLayer { }: scale and translation change per frame, so read them in the draw phase.
Clamp the pan**: at scale s the image can move at most (s - 1) * size / 2 each way before its edge leaves the viewport.
Inside a pager: transformable(state, canPan = { scale > 1f }) lets one-finger drags pass to the pager while the image is not zoomed.
Zoom towards the fingers: detectTransformGestures also gives the centroid; adjust the offset by it so the point under the fingers stays put.
Double tap to zoom: detectTapGestures(onDoubleTap = ...) plus an Animatable for the scale, so the jump animates.
For the standard case use Material 3's SwipeToDismissBox; for custom snap points use AnchoredDraggableState with DraggableAnchors and Modifier.anchoredDraggable, and move the content by its offset yourself.
Anchors map each state to a pixel position and usually depend on the measured size, so set them with updateAnchors from onSizeChanged. Until then the offset is NaN and requireOffset() throws.
**anchoredDraggable only handles the gesture**: apply the position with Modifier.offset { IntOffset(state.requireOffset().roundToInt(), 0) }; the lambda reads it during placement, so dragging does not recompose the row.
Settling rules: since Foundation 1.8 the positional threshold and the snap animation live in AnchoredDraggableDefaults.flingBehavior(...), not in the state constructor; the older constructor with thresholds is deprecated.
**currentValue vs settledValue**: currentValue follows the closest anchor the offset has passed, even mid-drag; settledValue changes only once the drag or animation has settled at an anchor. Delete the item on settledValue.
From code: state.animateTo(value) and state.snapTo(value) move it without a gesture, for example to close a revealed row.
drawWithCache splits drawing in two: the outer block runs only when the size or a state it reads changes, and the onDrawBehind or onDrawWithContent block it returns runs every frame, so expensive objects are built once and reused.
Why it matters: drawBehind runs its whole lambda on every draw pass. A Path, Brush, Stroke or text layout created there is garbage every frame, and GC pauses show up as dropped frames during animation.
**Read animated values only inside onDraw*: a state read in the cache block invalidates the cache on every change, and you are back to allocating per frame.
Size-dependent objects belong in the cache**: the cache is rebuilt when the size changes, so a path built from size stays correct.
Text: create rememberTextMeasurer() in the composable, call textMeasurer.measure(...) in the cache block, and drawText(layoutResult) in the draw block.
During the drag, snapTo the finger position so nothing animates; on release, hand the measured velocity to animateDecay to coast, or to animateTo(target, initialVelocity = v) to spring to a resting point without losing the throw.
During the drag
**stop() on touch down: a fling from the previous gesture may still be running and would fight the finger for the value.
snapTo(value + delta) per change**, launched in a coroutine. It is a suspend function that cancels any running animation; Animatable orders the writes through its MutatorMutex.
Velocity: Modifier.draggable hands it to onDragStopped. In a raw pointerInput loop, feed a VelocityTracker with addPosition(change.uptimeMillis, change.position) and call calculateVelocity() on release.
After release
**splineBasedDecay** (or rememberSplineBasedDecay()) matches platform flings and is density-aware. calculateTargetValue(current, velocity) predicts where a fling would land before you start it; that is how you choose between coasting away and springing back.
**updateBounds(lower, upper)** clamps the decay; it ends with AnimationEndReason.BoundReached, and you can spring back from there with the remaining velocity.
Traps
Keeping the offset in mutableStateOf and moving it by hand loses velocity, interruption and bounds; Animatable exists for exactly this.
Read the value in Modifier.offset { } or graphicsLayer { }, not in composition, or every frame recomposes the subtree.
Each scroll delta first goes to the parents' onPreScroll, outermost first, then to the scrolling child, and what is left travels back up through onPostScroll; a collapsing header shrinks in pre-scroll and expands only in post-scroll, once the list has reached its top.
The dispatch
onPreScroll(available, source) and onPostScroll(consumed, available, source) return the Offset they consumed. Flings mirror them with suspending onPreFling and onPostFling that take a Velocity.
Return exactly what you used: Offset.Zero passes everything on, and claiming more than you used makes the scroll stick.
source is NestedScrollSource.UserInput for a finger and SideEffect for a fling or animation (renamed from Drag and Fling in Compose UI 1.7).
The collapsing header
Finger moving up (available.y < 0): consume in onPreScroll until the header is fully collapsed, so it shrinks before the list moves.
Finger moving down: consume nothing in pre-scroll and take the leftovers in onPostScroll, which are non-zero only once the list is at the top. Consuming downward deltas in pre-scroll is the bug where the header expands before the list has scrolled back.
Keep the offset in mutableFloatStateOf and read it in a layout { } block, so a scroll frame re-lays out the header instead of recomposing it.
Before writing your own
Material 3 already ships TopAppBarDefaults.exitUntilCollapsedScrollBehavior(), enterAlwaysScrollBehavior() and pinnedScrollBehavior(), used with Modifier.nestedScroll(scrollBehavior.nestedScrollConnection). Inside a View CoordinatorLayout, rememberNestedScrollInteropConnection() bridges Compose scrolling to it.
Keep one layout whose parameters change, wrap it in a LookaheadScope, and put Modifier.animateBounds(lookaheadScope) on each item: the lookahead pass computes where every item is going, and each one animates its size and position towards that target.
Why the usual tools fall short
**animateItem** animates moves inside one lazy layout. Replacing a LazyColumn with a LazyVerticalGrid disposes every item and composes new ones, so there is nothing to move.
**animateContentSize animates one node's own size, not its position.
animate*AsState into an offset needs the destination in pixels, which only the parent knows after it measures.
The mechanism
LookaheadScope { } measures and places the subtree as if the change had already finished, then lets each child approach that result over several frames.
Modifier.animateBounds** (Compose 1.8, behind @ExperimentalSharedTransitionApi) is the ready-made size-plus-position animation; SharedTransitionLayout uses the same machinery with matched keys.
**Modifier.approachLayout is the low-level hook for custom behaviour: you measure with your own animated constraints and report when the approach is done, so the node returns to the normal single pass.
Moving between parents**: if a child genuinely changes parent (a Column becoming a Row), movableContentOf keeps its state across the move.
Traps
Nodes outside the scope have no target; the scope boundary is the animation boundary.
Items need stable keys, or Compose matches them by position and animates the wrong ones.
Modifier.blur works only on Android 12 (API 31) and newer, and AGSL RuntimeShader only on Android 13 (API 33); below that blur is a silent no-op, so design the fallback first (a gradient or a translucent scrim) and add the effect on top.
What exists
**Modifier.blur(radius, edgeTreatment)** is backed by RenderEffect; graphicsLayer { renderEffect = BlurEffect(...) } is the same thing at layer level.
It blurs the composable's own content, not what is behind it. Frosted glass over scrolling content means blurring a copy of the background yourself, or using a library such as Haze.
Window background blur is a separate feature: Window.setBackgroundBlurRadius on API 31+, which the system can switch off, so check WindowManager.isCrossWindowBlurEnabled().
**RuntimeShader** compiles an AGSL program. Use it as a fill through ShaderBrush, or run the composable's pixels through it with RenderEffect.createRuntimeShaderEffect(...).asComposeRenderEffect(). Update uniforms in the draw phase.
Shipping it
Gate once with Build.VERSION.SDK_INT in the composable that owns the effect and choose the whole look there, instead of scattering version checks through the tree.
Brush.linearGradient, radialGradient and sweepGradient work on every supported version and are the honest fallback.
Blur costs GPU time in proportion to radius and area. Animating the radius re-runs the effect every frame, so animate the alpha of a fixed-radius blur instead, and keep blur out of list items.