The press animation that never plays on a fast tap
animateFloatAsState retargets instead of queueing, so a 3% press dip is invisible on every real tap. Why sequential Flow collection is the whole fix, and how to test motion that no assertion can see.
- Animation
- Compose
- Interaction
FormaUI's buttons dip 3% when you press them. It's a small thing — a scale to 0.97 layered over the Material ripple — and the first implementation was two lines and obviously correct.
It worked in the interactive preview. It worked when I held a button down. It did nothing whatsoever when anyone tapped one, which is the only way anyone uses a button.
The version that looks right
Here it is, and I'd guess most Compose codebases contain something very close:
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f)
Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
Three idiomatic APIs, no cleverness, reads like a sentence. Hold the button and it dips exactly as intended. Tap it and you get a flicker of maybe a third of a percent, which on a real screen is indistinguishable from nothing.
Why: animateFloatAsState retargets, it doesn't queue
A tap is two interactions a few tens of milliseconds apart: PressInteraction.Press, then PressInteraction.Release.
animateFloatAsState is a single animation whose target you change. Press sets the target to 0.97 and the animation starts moving. Release sets it back to 1.0 — and retargeting doesn't restart or finish anything, it redirects the in-flight animation from wherever it currently is, preserving velocity. On a fast tap "wherever it currently is" is a couple of frames into a 3% journey. The animation dutifully turns around and goes home, having travelled almost no distance.
Springs make it worse in a way that's easy to miss: a spring approaches its target asymptotically and stops within a visibility threshold, so there is no moment where the value is 0.97 and could be observed. There's no "the dip happened" state to preserve, because the dip is an approach rather than an arrival.
None of this is a bug in animateFloatAsState. It is precisely correct for a value that tracks state — a colour that follows selection, a chevron rotation that follows expanded/collapsed. If the state flips back before the animation lands, cutting it short is what you want; the user changed their mind and the UI should follow.
A press dip isn't that. It's an acknowledgement of an event. The user tapped; the interface owes them a confirmation that it noticed; the confirmation has a minimum legible duration regardless of how fast they lifted their finger. Modelling an event as a state and interpolating toward it is the whole mistake, and it took me embarrassingly long to see because the code reads so well.
The reframe: a queue of events
val scale = remember { Animatable(1f) }
LaunchedEffect(interactionSource, pressedScale, downAnimationSpec, animationSpec) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press ->
scale.animateTo(pressedScale, downAnimationSpec)
is PressInteraction.Release, is PressInteraction.Cancel ->
scale.animateTo(1f, animationSpec)
}
}
}
return graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
The load-bearing word is collect, and it isn't doing anything animation-specific.
Flow.collect's body is a suspend function, and a flow will not emit its next value until the current invocation of that body returns. Animatable.animateTo suspends until its animation finishes. Put those two facts together: a Release that arrives while the dip is still playing cannot be handled until the dip completes. It waits its turn in the collector, then the spring-back plays from the fully-dipped position.
The dip always plays in full — not because of a special case or a minimum-duration guard, but because sequential collection is what collect means. The mechanism transfers, which is why it's worth remembering: any "this feedback must complete even if the state that triggered it is already gone" problem has the same shape, and the answer is a queue of events rather than a function of state.
Animatable rather than animateFloatAsState because we now need to drive the animation from a coroutine and observe its completion, which is exactly the boundary between those two APIs.
Two specs, not one
Guaranteeing the dip plays in full has an immediate consequence: the dip's duration is now a floor on how long the whole interaction takes. A bouncy 400ms spring on the way down means every tap feels like the button is thinking about it.
So the motion is split, and the two halves want opposite things:
| Phase | Spec | Why |
|---|---|---|
| Press dip | tween(100ms, FastOutSlowInEasing) | Deterministic and short. It runs under the finger where the user is already looking, and it's the part that can't be interrupted, so it has to be cheap. |
| Release | spring(DampingRatioLowBouncy, StiffnessMedium) | This one can be as expressive as you like — nothing is waiting behind it. The slight overshoot is what makes the release read as springy rather than mechanical. |
A fixed tween on the way down and a bouncy spring on the way back was not the first thing I'd have guessed. It came out of the constraint rather than taste, which is usually a sign a decision is right.
One more case in that when: PressInteraction.Cancel shares the release path. A press that turns into a scroll emits Cancel, not Release, and if you only handle Release the element stays shrunk permanently. That's a bug you find on a list, not on a demo screen.
Why graphicsLayer, and why the lambda form
Two separate traps live here.
Don't animate size. The tempting alternative is to shrink the element for real — animate a padding, a heightIn, a requiredSize. Do that and the measured bounds shrink with the visual, which means the touch target shrinks mid-gesture. FormaUI enforces a 48dp minimum on every button; an animation that quietly violates it during the exact moment the user is touching it would be a genuinely bad accessibility bug, and it would never show up in a screenshot test. graphicsLayer transforms at draw time, so the measured size — and the 48dp floor — is untouched:
val buttonModifier = modifier
.heightIn(min = FormaButtonDefaults.MinTouchTargetSize) // 48.dp, measured
.formaPressScale(interactionSource, pressedScale, pressAnimationSpec) // draw-time only
Read the animated value inside the lambda. These two lines are not equivalent:
graphicsLayer(scaleX = scale.value, scaleY = scale.value) // recomposes every frame
graphicsLayer { scaleX = scale.value; scaleY = scale.value } // draw-time read
The first reads scale.value during composition, so every frame of the animation invalidates the composable. The second defers the read into the layer block, which runs at draw — so a 60fps animation costs zero recompositions. The lambda overload of graphicsLayer exists for precisely this, and it's a free win on any animated transform.
Observe-only, which is the harder API decision
The modifier takes an InteractionSource, not a MutableInteractionSource, and it never detects input:
@Composable
fun Modifier.formaPressScale(
interactionSource: InteractionSource,
pressedScale: Float = FormaPressScaleDefaults.PressedScale,
downAnimationSpec: FiniteAnimationSpec<Float> = FormaPressScaleDefaults.DownAnimationSpec,
animationSpec: FiniteAnimationSpec<Float>? = FormaPressScaleDefaults.AnimationSpec,
): Modifier
It watches someone else's press interactions. The caller has to hand it the same source the element's clickable emits into — the pattern Modifier.indication already established:
val interactionSource = remember { MutableInteractionSource() }
Box(
Modifier
.formaPressScale(interactionSource)
.clickable(interactionSource = interactionSource, indication = ripple()) { … }
)
Detecting presses inside the modifier would have been a friendlier API — one argument fewer, nothing to wire up. It would also mean two independent gesture detectors on the same element, racing the ripple and double-counting a press. Observe-only is the correct decision, and it's worth being honest that it comes with the worst class of failure mode: pass two different sources and you get silence, not an error.
Inside a component you own, the wiring is one line and invisible to your users — FormaButton remembers a source, hands it to formaPressScale, and passes the same one down to the underlying M3 button. Exposing the modifier publicly means exposing the footgun; I'd rather ship the reusable primitive and document the trap than hide it and re-implement the animation in five components.
The animationSpec = null escape hatch returns the receiver unchanged, so disabling it adds no modifier nodes at all rather than a node that animates to 1.0.
Testing an animation nothing can see
Here's the part that connects to an earlier post about Canvas tests: the scale lives in a graphicsLayer. It is not in the semantics tree. There is no assertion that reads it. You cannot test the value.
So don't test the value — test the regression class. The bug was "a fast tap skips the dip," which means the thing to reproduce is the interleaving, not the pixels:
@Test
fun quickTap_playsFullDipAndSettles() {
// …setContent with a clickable Box carrying formaPressScale…
// Pause the clock so Release is guaranteed to arrive before the dip has
// played a single frame — the exact interleaving the old version broke on.
composeRule.mainClock.autoAdvance = false
pressable.performTouchInput { down(center); up() }
composeRule.mainClock.advanceTimeBy(2_000)
composeRule.mainClock.autoAdvance = true
composeRule.waitForIdle()
pressable.assertIsDisplayed()
composeRule.runOnIdle { assertEquals(1, clicks) }
// The queue must be fully drained and interactive again — this is what
// would wedge if sequential collection misbehaved.
pressable.performClick()
composeRule.runOnIdle { assertEquals(2, clicks) }
}
mainClock.autoAdvance = false is what makes this a real test rather than a race. With the clock paused, down() and up() both land before any animation frame runs — a harsher interleaving than a human hand can produce, and precisely the one that used to fail.
The assertions are indirect on purpose. Still displayed, exactly one click, and — the important one — still interactive afterwards. A sequential collector that wedged, or an infinite spec that never completed, would block the flow forever and the second performClick() would fail. That's the real failure mode being guarded, and it's observable even though the animation isn't.
The companion test for the disable path leans on identity:
composeRule.runOnIdle { assertSame(Modifier, result) }
If animationSpec == null returns the receiver unchanged, and the receiver was Modifier itself, then identity equality proves no node was added. One assertion, no inspection of internals.
What generalises
- Feedback about an event is not a function of state. If a piece of motion must complete regardless of how quickly the triggering state reverts,
animateFloatAsStateis the wrong tool and no spec tuning will save it. collectgives you sequencing for free. A suspending collector body plus a suspendinganimateTois a queue. You don't need a state machine or a minimum-duration timer.- Split interruptible motion from uninterruptible motion. If one phase blocks the next, make that phase short, fixed, and boring; spend the expressiveness on the phase nothing is waiting for.
- Handle
Cancel, not justRelease. Scrolling a list over your component is the common path, not the edge case. - Transform at draw time, never at measure time, or your touch target animates along with your visual.
- Read animated values inside the
graphicsLayerlambda, not as arguments to it. - When the thing you changed isn't observable, test the failure it caused. Pause the clock, force the bad interleaving, and assert the component is still alive on the other side.
The final implementation is about thirty lines, five of the forty components use it, and it took considerably longer to get right than "scale the button a bit when you press it" has any business taking.
FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. Modifier.formaPressScale is public API, so you can put the interaction on your own components; it's on by default for buttons, icon buttons, cards, chips and FABs. Try them live in your browser — the dip is easier to feel than to read about.