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
| Mistake | Why it hurts |
|---|---|
Using animateTo instead of snapTo while the finger is still dragging | The 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.id | An unrelated key that changes every recomposition restarts the gesture-detection coroutine mid-drag |