skip to content

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?

level: seniorimportance: should knowfreq 22%

answer

  1. values clamped to the bounds
  2. AnimationController.unbounded
  3. animateWith status is always forward
  4. fling rejects underdamped springs
  5. snapToEnd and Tolerance 0.001

basics

~20 s

animateWith 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 s

A 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 lines
dart
import '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

for a junior

Remember that a default controller only produces values between 0 and 1, so anything a spring does outside that range is cut off.

for a middle

Explain clamping, why animateWith reports forward and completed regardless of direction, and when to use animateBackWith or unbounded.

for a senior

Diagnose from symptoms to source: clamping, status, fling's underdamped assertion, tolerance and snapToEnd, and canceled TickerFutures, and turn them into a review checklist.

for a principal

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.