Gesture-Driven Animations

Every animation in this track so far has been triggered by a state change your own code decided on: a tap toggled a boolean, a load finished. But some of the most satisfying motion in mobile UI is driven directly by the user's finger while it's still touching the screen — a card that follows your drag in real time, then either commits to an action or snaps back depending on how far you dragged. That needs a lower-level tool than anything covered so far: raw pointer input combined with an Animatable you drive yourself.

Modifier.pointerInput{}

Modifier.pointerInput opens a coroutine scope scoped to a composable's pointer events. Everything about reading touch — drags, taps, multi-touch — happens inside the suspend lambda you pass it. The key argument (often a stable id) controls when Compose restarts that coroutine; passing the same key across recompositions keeps an in-progress gesture from being cancelled by an unrelated recomposition:

The shape every gesture handler starts from

Modifier.pointerInput(recipe.id) {
    // detectDragGestures / detectTapGestures / awaitPointerEventScope go here
}

detectTapGestures

For simple tap-family interactions — tap, double-tap, long-press — without hand-rolling hit testing, detectTapGestures gives you one callback per gesture kind:

Tap to open, long-press to enter multi-select

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { onOpenRecipe(recipe.id) },
        onLongPress = { onEnterMultiSelect(recipe.id) }
    )
}

detectDragGestures + Animatable

Dragging is different: you need a value that follows the finger continuously, then animates the rest of the way once the finger lifts. That's exactly what a raw Animatable is for — unlike animate*AsState, you control it directly with snapTo (jump instantly, no animation, for following the finger) and animateTo (animate smoothly, for the release):

The drag lifecycle

val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()

Modifier.pointerInput(recipe.id) {
    detectDragGestures(
        onDrag = { change, dragAmount ->
            change.consume()
            scope.launch { offsetX.snapTo(offsetX.value + dragAmount.x) }
        },
        onDragEnd = {
            scope.launch { offsetX.animateTo(0f) }
        }
    )
}

change.consume() tells Compose this pointer event has been handled, so it doesn't also get interpreted as a scroll on a parent list. onDrag uses snapTo because the offset should track the finger exactly, with no animation lag; onDragEnd switches to animateTo because that's where the actual motion — snapping back, or committing to an action — should be smoothly animated.

Worked Example: RecipeBox's Swipe-to-Delete Card

RecipeBox lets a user swipe a recipe card left to reveal a delete action underneath it. Drag it far enough and releasing commits to deleting the recipe (animating the card off-screen first); drag it only a little and releasing snaps the card back to its resting position:

RecipeBox: swipe-to-delete recipe card

@Composable
fun SwipeToDeleteRecipeCard(
    recipe: Recipe,
    onDelete: () -> Unit
) {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val deleteThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }

    Box(modifier = Modifier.fillMaxWidth()) {
        // The delete action revealed behind the card
        Row(
            modifier = Modifier
                .matchParentSize()
                .background(MaterialTheme.colorScheme.errorContainer),
            horizontalArrangement = Arrangement.End,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                imageVector = Icons.Filled.Delete,
                contentDescription = "Delete recipe",
                modifier = Modifier.padding(end = 24.dp)
            )
        }

        RecipeCard(
            recipe = recipe,
            modifier = Modifier
                .offset { IntOffset(offsetX.value.roundToInt(), 0) }
                .pointerInput(recipe.id) {
                    detectDragGestures(
                        onDrag = { change, dragAmount ->
                            change.consume()
                            scope.launch {
                                offsetX.snapTo((offsetX.value + dragAmount.x).coerceAtMost(0f))
                            }
                        },
                        onDragCancel = {
                            scope.launch { offsetX.animateTo(0f) }
                        },
                        onDragEnd = {
                            scope.launch {
                                if (offsetX.value < -deleteThresholdPx) {
                                    offsetX.animateTo(
                                        targetValue = -1000f,
                                        animationSpec = tween(200)
                                    )
                                    onDelete()
                                } else {
                                    offsetX.animateTo(
                                        targetValue = 0f,
                                        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
                                    )
                                }
                            }
                        }
                    )
                }
        )
    }
}

coerceAtMost(0f) stops the card from being dragged to the right past its resting position, since only a left swipe reveals the delete action. Crossing deleteThresholdPx (120dp worth of pixels) and releasing animates the card the rest of the way off-screen before calling onDelete(); releasing short of that threshold, or the drag being cancelled entirely (onDragCancel, which fires if e.g. a parent scrollable steals the gesture), animates the card back to 0f with a springy snap.

Common Pitfalls

MistakeWhy it hurts
Using animateTo instead of snapTo while the finger is still draggingThe offset lags behind the finger instead of tracking it, which feels laggy and wrong
Forgetting change.consume()A parent scrollable can intercept the same gesture, causing the list to scroll instead of (or as well as) the card sliding
Not keying pointerInput on something stable like recipe.idAn unrelated key that changes every recomposition restarts the gesture-detection coroutine mid-drag
Next up: That closes out this track's animation section. Custom Layouts starts a new section on advanced composition, beginning with writing your own layout logic from scratch.