Animatable and Gesture-Driven Animations in Jetpack Compose

Quick answer: use
Animatablewhen a value must be controlled from a coroutine—especially when touch input and animation both update it. Stop the current motion when a new touch begins, usesnapTowhile the finger is dragging, then useanimateTooranimateDecaywhen the gesture ends. For ordinary state-to-value motion, preferanimate*AsStateinstead.
Animatable is Compose’s coroutine-based holder for a single animating value. It is a strong fit for a custom drag, fling, or sequence because it exposes operations that target-based APIs intentionally hide: snapTo, stop, animateTo, and animateDecay.
The official value-based animation guide describes Animatable as the lower-level API behind animate*AsState. Using it directly gives you fine-grained control, but it also means you own the coroutine lifecycle and gesture policy.
Know when you actually need Animatable
| Situation | Prefer | Reason |
|---|---|---|
| A scale, color, or alpha follows regular UI state | animate*AsState | Compose owns the target changes declaratively. |
| Several values share one named screen transition | updateTransition | The relationship between values is explicit. |
| A value must stop, snap under a finger, fling, or run in sequence | Animatable | Suspend operations provide that imperative control. |
| A drawer, sheet, slider, or other standard control | A Material/Foundation component | It already handles gestures, semantics, and settling. |
The last row matters. A custom swipe modifier is educational and occasionally necessary, but it is not the starting point for a standard component. See click, drag, swipe, and gesture detection in Compose for the higher-level gesture choices first.
The mental model: one mutable presentation value
Create an Animatable with remember, then read its value from the UI. Its animation functions are suspend, so launch them from an appropriate coroutine scope.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
val alpha = remember { Animatable(0f) }
LaunchedEffect(visible) {
if (visible) {
alpha.snapTo(0f)
alpha.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 180),
)
} else {
alpha.animateTo(0f, animationSpec = tween(durationMillis = 120))
}
}Unlike a normal state variable, an Animatable has a current value, velocity, target, and running state. Calling animateTo while another animateTo or animateDecay is running cancels the old animation and starts the next one from the current value. With the default spring, it also continues velocity smoothly. The Animatable API reference calls this mutual exclusiveness out explicitly.
That cancellation is usually desirable for input: the person touching the screen should immediately take control. It does mean that code after an animation call is not guaranteed to run. If cleanup must occur after cancellation, use try/finally or handle CancellationException; do not put important application work after animateTo and assume it always completes.
The gesture handoff has four phases
For a custom swipe interaction, the handoff between finger and physics should be deliberate:
- Touch down: cancel any settling or fling with
stop(). - Drag: set the visual value directly with
snapTo()so it follows the finger. - Release: measure velocity and predict where a decay would stop.
- Settle: spring back with
animateTo()or continue momentum withanimateDecay()within bounds.
The advanced gesture animation guide uses exactly this approach: stop at touch-down, snapTo during drag, and velocity plus animateDecay after release.
A custom swipe-to-dismiss modifier
The following illustrative modifier keeps one horizontal offset in an Animatable. It treats an item as dismissed only when the projected fling crosses the item’s width; otherwise it springs back to zero.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationEndReason
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.consume
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.IntOffset
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
fun Modifier.swipeToDismiss(
onDismissed: () -> Unit,
): Modifier = composed {
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
val decay = splineBasedDecay<Float>(this)
coroutineScope {
while (true) {
val velocityTracker = VelocityTracker()
// A new touch always has priority over a previous settle animation.
offsetX.stop()
awaitPointerEventScope {
val pointerId = awaitFirstDown().id
horizontalDrag(pointerId) { change ->
launch {
offsetX.snapTo(
offsetX.value + change.positionChange().x,
)
}
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
change.consume()
}
}
val velocity = velocityTracker.calculateVelocity().x
val projectedOffset = decay.calculateTargetValue(
offsetX.value,
velocity,
)
val dismissDistance = size.width.toFloat()
offsetX.updateBounds(
lowerBound = -dismissDistance,
upperBound = dismissDistance,
)
launch {
if (projectedOffset.absoluteValue <= dismissDistance) {
offsetX.animateTo(
targetValue = 0f,
initialVelocity = velocity,
)
} else {
val result = offsetX.animateDecay(velocity, decay)
if (result.endReason == AnimationEndReason.BoundReached) {
onDismissed()
}
}
}
}
}
}.offset {
IntOffset(offsetX.value.roundToInt(), 0)
}
}This is deliberately a custom primitive, not a replacement for a Material dismissible component. Integrate it with a parent that owns the list item and performs removal only after onDismissed fires. The offset remains presentation state; the data item should not vanish merely because it is halfway through a drag.
Why each operation is necessary
stop() prevents an old spring-back or fling from fighting the person’s next drag. snapTo() is instant, so the object remains attached to the finger rather than trailing behind an animation. VelocityTracker records the drag path; its final velocity becomes the initial velocity of a decay or return spring.
splineBasedDecay provides a platform-aware decay model. calculateTargetValue lets the modifier decide whether the projected fling should dismiss or return before it starts the final animation. This creates one consistent decision rule for a slow long drag and a fast short fling.
The call to updateBounds caps the physics at each side of the component. If an animation reaches a bound, animateTo or animateDecay reports AnimationEndReason.BoundReached. Check that result before treating an item as dismissed; an animation may be interrupted by a new gesture.
Use bounds and cancellation intentionally
Animatable supports lower and upper bounds for the animated type. Updating bounds clamps a non-running value immediately; while an animation is running, Compose checks the bound on a later frame and ends the animation if it reaches it. The API reference documents both behaviors.
There are two related but different outcomes to handle:
- An animation reaches a bound: it returns an
AnimationResultwithBoundReached. - A new
animateTo,animateDecay,snapTo, orstopinterrupts it: its coroutine is cancelled.
Only the first is a valid signal that a bounded fling completed. The second is normal interaction behavior, not an error and not a reason to delete a list item.
Keep the value in the drawing or layout phase
An Animatable changes every frame. Consume it in the narrowest rendering phase that matches the effect.
Box(
Modifier.offset {
IntOffset(offsetX.value.roundToInt(), 0)
},
)Using the lambda form of offset defers the value read to layout. For pure visual transforms such as alpha, rotation, or scale, a graphicsLayer can keep invalidation in drawing instead. This is not a mandate to avoid layout animation; animate layout when the component genuinely needs to move in layout.
Choose animateTo or animateDecay after release
Use animateTo when the destination is known: return a card to 0f, snap a knob to the nearest anchor, or finish a reveal at a measured position. Pass initialVelocity immediately after the fling so the motion remains continuous.
Use animateDecay when the release velocity should decide how far motion continues. Decay has no explicit target; it slows from the initial velocity until it settles or reaches a bound. The Compose animation documentation identifies this as the standard primitive for fling behavior.
Do not pass a stale velocity from a prior interaction into a later animateTo. The Animatable reference recommends setting initialVelocity only immediately after a fling; otherwise the result can look discontinuous.
Common mistakes
Using Animatable for every state change
It adds a coroutine and cancellation behavior that a normal target-based animation does not need. Use animate*AsState for a value that simply follows state; this guide to animate*AsState covers that simpler path.
Updating the screen’s data during the drag
A partially dragged item is still part of the screen state. Let the gesture update presentation offset, then tell the parent about a confirmed dismissal after the bounded decay completes.
Letting an old animation fight a new finger
Always interrupt settling at touch-down. Skipping stop() causes visible resistance because the animation continues to write values while the drag is trying to write them too.
Treating cancellation as dismissal
An interrupted animation throws cancellation through its coroutine. It did not finish the gesture. Use AnimationResult.endReason for the bounded completion case and keep cleanup cancellation-safe.
Building custom gestures before checking Foundation or Material
Custom pointer input is responsible for gesture competition, semantics, keyboard alternatives, and testing. Prefer a standard component unless the interaction is genuinely unique.
Test the interaction, not just the resting state
- Test a short drag that returns to its origin.
- Test a fast fling whose velocity dismisses the item even from a shorter distance.
- Begin a second drag while a return animation is running.
- Test cancellation, nested scrolling, different widths, and layout direction.
- Give the same destructive action a visible, accessible alternative; swipe should be a shortcut, not the only path.
For visual motion that needs a manual tune-up, use Android Studio’s Animation Preview. For behavior, assert the domain event—such as the parent receiving onDismissed—rather than relying only on a transient pixel position.
FAQ
Is Animatable imperative Compose code?
It is coroutine-controlled, but it still exposes observable state to Compose. Keep domain state declarative in the parent and use Animatable for the temporary presentation value that needs direct motion control.
Does snapTo animate the value?
No. It changes the value immediately. That is why it works during a drag: every pointer update can place the element exactly under the finger.
When should a drag use animateDecay?
Use decay after release when velocity should carry the element forward like a fling. Use animateTo when the interaction must settle at one known value or anchor.
Summary
Animatable is the right tool when input and animation must share one value. Stop on touch-down, snap during drag, preserve velocity at release, and make data changes only after a confirmed settling outcome. That small protocol produces gesture-driven motion that feels responsive without turning rendering details into business state.