Compose Animation & Gestures
Irbisa · cheatsheetSeptember 13, 2026

Compose Animation & Gestures

animate*AsState, Transition, AnimatedContent, Animatable, pointerInput, drag, nestedScroll, Canvas

Middle Developer16 itemscompressed for a skim
  1. 01

    A progress bar animated with animateFloatAsState has to jump straight back to 0 on retry, and instead it slides back. What do you switch to?

    Easy

    animate*AsState hides its value behind a declarative API that can only ever animate towards a new target, so an instant reset needs Animatable, 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
    …
  2. 02

    AnimatedContent(targetState = count) { Text("Total: $count") } swaps the number with no visible transition at all. What is wrong?

    Easy

    The 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")
    }
    …
  3. 03

    Sorting a LazyColumn makes every row teleport to its new position and a freshly inserted row pops in. What turns that into motion?

    Medium

    Modifier.animateItem() on the item's root animates placement, appearance and disappearance — but only for items with stable keys, 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
    …
  4. 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?

    Medium

    Both composables must sit under one SharedTransitionLayout, carry Modifier.sharedElement with the same key, and each be inside an AnimatedVisibilityScope — 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") {
    …
  5. 05

    A designer specs "300ms ease-out", but the motion must also survive being interrupted halfway — tween, spring or keyframes, and what is visibilityThreshold protecting?

    Medium

    Take the spring unless the exact curve is the point, because a spring carries the current velocity into a retarget while a tween restarts 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",
    )
    …
  6. 06

    Your draggable card follows the finger perfectly but stops dead the instant it is released. What does the Animatable need during the drag and after it?

    Medium

    During the drag you snapTo each new value so nothing animates, and on release you feed the tracked velocity into animateDecay — or into animateTo(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
    …
  7. 07

    A row's Modifier.pointerInput(Unit) block keeps calling the previous onSelect lambda after the callback changes. What is the key doing, and what is the fix?

    Medium

    The pointerInput block is a coroutine launched once and cancelled and restarted only when its keys change, so with Unit as the key it captured the first onSelect and 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 —
    …
  8. 08

    You swap Modifier.clickable for pointerInput { detectTapGestures(...) } to add a double tap, and QA files three bugs. Which three?

    Medium

    No ripple, nothing for TalkBack to activate, and no keyboard or D-pad focus — clickable is not a tap listener, it is a tap listener plus indication, semantics and focus, and detectTapGestures is 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() }
    …
  9. 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?

    Medium

    AnchoredDraggableState has nowhere to settle until it is given anchors, and until updateAnchors has run its offset is Float.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. 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?

    Medium

    The header is consuming the downward delta in onPreScroll, where it gets first refusal; expanding belongs in onPostScroll, 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. 11

    Adding a Modifier.pointerInput drag handle to a row killed the LazyColumn's scrolling — how does Compose decide which gesture wins?

    Medium

    There 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. 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?

    Medium

    Alpha is being applied to each drawing command separately instead of to the composited group, and compositingStrategy is 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. 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?

    Hard

    drawWithCache splits the modifier in two: the outer block re-runs only when the size changes or a state it read changes, and the onDraw* block it returns runs every frame — so the Path and the Brush are built once and reused.

    @Composable
    fun Gauge(progress: Float, modifier: Modifier = Modifier) {
      val sweep by animateFloatAsState(progress * 270f, label = "sweep")
    
      Box(
        modifier
    …
  14. 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?

    Hard

    Modifier.blur is real only from Android 12 (API 31) and RuntimeShader with 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. 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?

    Hard

    Wrap 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. 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?

    Hard

    Compose reads the system animator duration scale through a MotionDurationScale element 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)) }
    }
    …