In a Flutter flash-card app, a spring-back driven by AnimationController.animateWith never overshoots and reports completed at zero; what is wrong and how do you fix it?
answer
- values clamped to the bounds
- AnimationController.unbounded
- animateWith status is always forward
- fling rejects underdamped springs
- snapToEnd and Tolerance 0.001
basics
~20 sanimateWith clamps simulated values to the controller's 0..1 bounds and always reports forward then completed; use AnimationController.unbounded for springs that leave the range, and check the value or use animateBackWith instead of waiting for dismissed.
solid answer
~30 sA default `AnimationController` clamps every `simulation.x(t)` to `lowerBound`..`upperBound`, so an underdamped spring's overshoot, or a negative starting velocity, is flattened; `AnimationController.unbounded` removes the bounds and is what the framework recommends for physics. `animateWith` also reports `AnimationStatus.forward` for the whole run and `completed` at the end, even when the value falls back to 0, so a listener waiting for `dismissed` never fires; use `animateBackWith` or read the value. Related traps: `fling` asserts that its spring is not underdamped, a spring is done within a 0.001 tolerance unless you pass `snapToEnd: true`, and a new `animateWith` cancels the previous `TickerFuture`.
code
dart · 54 linesimport 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
class SpringBackCard extends StatefulWidget {
const SpringBackCard({super.key});
@override
State<SpringBackCard> createState() => _SpringBackCardState();
}
class _SpringBackCardState extends State<SpringBackCard>
with SingleTickerProviderStateMixin {
// Bug: AnimationController(vsync: this) clamps to 0..1 and flattens overshoot.
late final AnimationController _dx = AnimationController.unbounded(vsync: this);
Future<void> _release(double offsetPx, double velocityPx) async {
final spring = SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 400),
bounce: 0.3,
);
try {
// snapToEnd lands exactly on 0 instead of within the tolerance.
await _dx
.animateWith(
SpringSimulation(spring, offsetPx, 0, velocityPx, snapToEnd: true),
)
.orCancel;
} on TickerCanceled {
return; // A new drag or release took over this run.
}
// Status is AnimationStatus.completed here although the value is 0.
}
@override
void dispose() {
_dx.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragDown: (_) => _dx.stop(),
onHorizontalDragUpdate: (d) => _dx.value += d.delta.dx,
onHorizontalDragEnd: (d) => _release(_dx.value, d.primaryVelocity ?? 0),
child: AnimatedBuilder(
animation: _dx,
builder: (context, child) =>
Transform.translate(offset: Offset(_dx.value, 0), child: child),
child: const Card(child: SizedBox(width: 280, height: 180)),
),
);
}
}go deeper
Remember that a default controller only produces values between 0 and 1, so anything a spring does outside that range is cut off.
Explain clamping, why animateWith reports forward and completed regardless of direction, and when to use animateBackWith or unbounded.
Diagnose from symptoms to source: clamping, status, fling's underdamped assertion, tolerance and snapToEnd, and canceled TickerFutures, and turn them into a review checklist.
Weigh whether physics-driven controllers should be wrapped in a shared helper so teams stop rediscovering clamping and status traps screen by screen.
## The symptoms and their causes Physics-driven animations fail quietly: nothing throws in release mode, the card simply moves wrongly. The causes are all in how `AnimationController` wraps a `Simulation`. | Symptom | Cause in the source | Fix | |---|---|---| | Bouncy spring never overshoots; card freezes at release before moving | `animateWith` clamps `simulation.x(t)` to `lowerBound`..`upperBound` (0..1 by default) | `AnimationController.unbounded`, bounds at negative and positive infinity | | Listener waiting for `dismissed` never fires on the return trip | `animateWith` always reports `forward`, then `completed` | check the value, or use `animateBackWith` (reports `reverse`, then `dismissed`) | | Card rests at 0.0004 instead of 0 | a spring is "done" within its `tolerance` (0.001 by default) | `SpringSimulation(..., snapToEnd: true)` | | Debug assertion from `fling` with a bouncy spring | `fling` rejects `SpringType.underDamped` | use `animateWith(SpringSimulation(...))` for bounce | | `await` on the previous animation hangs forever | a new `animateWith` calls `stop()`, canceling the old `TickerFuture` | await `.orCancel` and handle `TickerCanceled` | | Fling jumps almost instantly on some devices | with `AnimationBehavior.normal`, `fling` multiplies the velocity by 200 when the platform asks to disable animations | pass `animationBehavior: AnimationBehavior.preserve` if the motion must play | ## Clamping, in detail Every tick runs `_value = clampDouble(_simulation!.x(elapsedInSeconds), lowerBound, upperBound)`. For a spring from 0 to 1 with ratio 0.6, the true path crosses 1.0 and comes back; the controller reports 1.0 for that stretch, so the card stops dead at the target and the bounce disappears. For a negative release velocity (the learner flicks the card *away* from home), the simulation dips below 0 first; the controller pins it to 0 and the card appears frozen until the spring turns around. The source doc comment on `AnimationController.unbounded` says it is "most useful for animations that will be driven using a physics simulation" — use it and put real pixel offsets in the value. ## Status is about direction, not position A common bug is `addStatusListener((s) { if (s == AnimationStatus.dismissed) ... })` expecting a signal when the card is back at 0. `animateWith` sets the direction to forward regardless of where the simulation goes, so the terminal status is `completed`. Either react to `completed` from the call you made, compare `controller.value`, or run the return with `animateBackWith` so the status reads `reverse` and ends at `dismissed`. ## `fling` versus `animateWith` `fling({velocity = 1.0, springDescription, animationBehavior})` is a convenience for "throw to one end": - The sign of `velocity` picks the target: negative heads to `lowerBound`, positive to `upperBound` (each padded by 0.01 so the spring crosses and finishes). - The default spring is `SpringDescription.withDampingRatio(mass: 1.0, stiffness: 500.0)`, critically damped. - It asserts that the spring is not underdamped — an oscillating spring would not "fling". In release builds the assertion is stripped, so the mistake passes unnoticed. - It ignores `duration` just like `animateWith`. Use `fling` for drawers and sheets that snap fully open or closed; use `animateWith` with your own `SpringSimulation` when you need a specific end value, a bounce, or pixel units. ## Settling exactly A `SpringSimulation` reports `isDone` when both displacement from the end and velocity are within `tolerance` (`Tolerance.defaultTolerance`, 0.001 for distance, time and velocity). The last emitted value may therefore sit a hair away from the target. The `snapToEnd` flag makes `x` return exactly `end` and `dx` return 0 once done; you can also pass a custom `tolerance` when the value is in pixels and 0.001 is finer than needed. ## A review checklist 1. Is the controller unbounded whenever the simulation can leave 0..1? 2. Does any status listener assume `dismissed` after `animateWith`? 3. Is `fling` being given an underdamped spring? 4. Are awaited futures using `.orCancel` with `TickerCanceled` handled? 5. Is the controller stopped on touch-down and disposed in `dispose`?
- Why does fling refuse an underdamped spring when animateWith accepts one?`fling` aims the spring slightly past a bound so it finishes by crossing it; an underdamped spring would oscillate around that target instead of settling, which is not a fling. The source asserts `simulation.type != SpringType.underDamped` and its message tells you to use `animateWith` with an explicit `SpringSimulation` if the bounce is intended.
- What does AnimationBehavior change for fling?With `AnimationBehavior.normal`, the default for the standard constructor, `fling` multiplies its velocity by 200 when the platform asks for animations to be disabled, so the motion completes almost instantly. `AnimationBehavior.preserve`, the default for `unbounded`, keeps the original velocity. You can override it per call with the `animationBehavior` argument.
saying these in an interview costs you the question
- animateWith reports dismissed whenever the value ends at zero.
- The controller widens its bounds automatically to fit a spring's overshoot.
- fling works with any spring, bouncy ones included.
- A spring simulation always lands exactly on its end value.
- Awaiting the old animateWith future is safe after starting a new one.