In Flutter, how do you make a released flash card spring back to centre using the DragEndDetails velocity and AnimationController.animateWith?
answer
- keep the finger's speed at release
- details.velocity.pixelsPerSecond
- pixels to controller units
- SpringSimulation(spring, start, end, velocity)
- unbounded controller keeps the overshoot
basics
~20 sBuild a SpringSimulation from a SpringDescription, the start and rest positions, and the DragEndDetails velocity converted into controller units, then pass it to AnimationController.animateWith, which ticks the simulation until it settles and ignores the controller's duration.
solid answer
~40 sOn `onPanEnd`, read `details.velocity.pixelsPerSecond` and convert it into the units the controller's value uses: for a controller running 0 to 1 along an `AlignmentTween` from the release point to `Alignment.center`, that is the velocity along the return path divided by the path length. Build `SpringSimulation(spring, 0, 1, velocity)` with a `SpringDescription` and call `_controller.animateWith(simulation)`; the controller evaluates the simulation every frame and stops when `isDone` is true, so no `duration` is needed. Call `_controller.stop()` in `onPanDown` so the learner can grab the card mid-flight. Create the controller with `AnimationController.unbounded` if the spring may overshoot or start with a negative velocity, because a bounded controller clamps every value to 0..1.
code
dart · 74 linesimport 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
class FlashCard extends StatefulWidget {
const FlashCard({super.key, required this.child});
final Widget child;
@override
State<FlashCard> createState() => _FlashCardState();
}
class _FlashCardState extends State<FlashCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller =
AnimationController.unbounded(vsync: this);
Animation<Alignment> _spring = const AlwaysStoppedAnimation(Alignment.center);
Alignment _alignment = Alignment.center;
@override
void initState() {
super.initState();
_controller.addListener(() {
setState(() => _alignment = _spring.value);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _springBack(Offset pixelsPerSecond, Size size) {
final Alignment start = _alignment;
_spring = _controller.drive(
AlignmentTween(begin: start, end: Alignment.center),
);
// One Alignment unit is half the width (or height) in pixels.
final Offset v = Offset(
pixelsPerSecond.dx / (size.width / 2),
pixelsPerSecond.dy / (size.height / 2),
);
final Offset toCentre = Offset(-start.x, -start.y);
final double lengthSquared = toCentre.distanceSquared;
// Velocity along the return path, in controller units per second.
final double velocity = lengthSquared == 0
? 0
: (v.dx * toCentre.dx + v.dy * toCentre.dy) / lengthSquared;
final spring = SpringDescription.withDampingRatio(
mass: 1,
stiffness: 300,
ratio: 0.6,
);
_controller.animateWith(SpringSimulation(spring, 0, 1, velocity));
}
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.sizeOf(context);
return GestureDetector(
onPanDown: (_) => _controller.stop(),
onPanUpdate: (details) => setState(() {
_alignment += Alignment(
details.delta.dx / (size.width / 2),
details.delta.dy / (size.height / 2),
);
}),
onPanEnd: (details) =>
_springBack(details.velocity.pixelsPerSecond, size),
child: Align(alignment: _alignment, child: Card(child: widget.child)),
);
}
}go deeper
Remember the shape: read the release velocity in onPanEnd, build a SpringSimulation, and hand it to the controller's animateWith instead of calling forward.
Explain the unit conversion from pixels per second to controller units, why animateWith ignores duration, and why the controller must be stopped when a new drag starts.
Show you know the traps: clamping on a bounded controller, forward status even on a downward run, and disposal. Pick unbounded controllers for any spring that may overshoot.
Frame the choice between physics-driven and duration-driven motion for a whole gesture-heavy app, and how a shared helper keeps velocity conversion consistent across screens.
## Why a simulation instead of `forward()` A flash card that the learner drags and lets go is already moving. If you answer the release with `controller.forward()` and a curve, the animation starts from zero velocity and runs for a fixed `duration`, whatever the finger was doing: a gentle nudge and a hard throw both take the same time, and the card visibly stops before it turns around. Flutter's physics layer solves this. A **`Simulation`** (in `package:flutter/physics.dart`) is an object that answers three questions for any time `t` in seconds: `x(t)` the position, `dx(t)` the velocity, and `isDone(t)` whether it has settled within its `tolerance`. **`AnimationController.animateWith(simulation)`** hands the controller that object; on every ticker frame the controller sets its value to `x(elapsedSeconds)` and stops when `isDone` returns true. The controller's `duration` is not used at all. ## The pieces | Piece | Flutter class | Role | |---|---|---| | Release speed | `DragEndDetails.velocity.pixelsPerSecond` | the finger's `Offset` velocity at lift-off, in logical pixels per second | | Spring constants | `SpringDescription` | mass, stiffness and damping of the spring | | The motion | `SpringSimulation(spring, start, end, velocity)` | position over time from `start` towards `end`, starting at `velocity` | | The clock | `AnimationController` + `animateWith` | ticks the simulation once per frame | | The mapping | `AlignmentTween` via `controller.drive` | turns controller units into an `Alignment` for `Align` | Note that `package:flutter/material.dart` re-exports only `Simulation` and `SpringDescription` from the physics library; `SpringSimulation` needs `import 'package:flutter/physics.dart'`. ## Step by step 1. Create the controller in the `State` with a ticker provider (`SingleTickerProviderStateMixin`) and dispose it in `dispose`. 2. While dragging, move the card directly: `onPanUpdate` adds `details.delta` (converted to alignment units) to the card's `Alignment` inside `setState`. 3. In `onPanDown`, call `_controller.stop()` so a learner can grab a card that is still springing back. 4. In `onPanEnd`, build an `AlignmentTween` from the release point to `Alignment.center` and bind it with `_controller.drive(...)`. 5. Convert the release velocity into controller units, build a `SpringSimulation(spring, 0, 1, velocity)` and call `_controller.animateWith(simulation)`. 6. Feed each controller value back into the card, either through a listener with `setState` or an `AnimatedBuilder`. ## Converting the velocity The simulation's units are whatever the controller's value means. Here the value runs from 0 (release point) to 1 (centre), so the velocity must be expressed as "fractions of the return path per second": - An `Alignment` of -1..1 spans the whole width, so one alignment unit is `size.width / 2` pixels horizontally and `size.height / 2` vertically. - Project the alignment-space velocity onto the vector from the release point to the centre, then divide by that vector's length again: `(v · toCentre) / |toCentre|²`. - A throw **away** from the centre yields a negative value: the card keeps travelling outward briefly, then the spring pulls it back — which is exactly the continuity the user expects. - Passing raw pixels per second instead makes the card leap, because 1200 would mean "1200 whole return paths per second". ## Pitfalls interviewers probe - **Clamping.** `animateWith` clamps every simulated value to the controller's `lowerBound` and `upperBound` (0 and 1 by default). A bouncy spring that overshoots the centre, or a negative starting velocity, gets flattened. `AnimationController.unbounded` sets the bounds to negative and positive infinity, and it is the constructor the framework recommends for physics-driven controllers. - **Status.** `animateWith` reports `AnimationStatus.forward` for the whole run and `completed` at the end, even when the value is travelling downwards; `animateBackWith` is the mirror image with `reverse` and `dismissed`. - **Fighting the drag.** Without `stop()` on touch, the controller's listener keeps overwriting the alignment while the finger moves the card. - **Disposal.** A controller used after `dispose()` trips an assertion; a controller never disposed keeps its ticker alive. The drag callbacks themselves (`onPanUpdate`, the gesture arena, `VelocityTracker`) belong to gesture detection; the spring constants are chosen through `SpringDescription`, covered separately.
- Why not call forward() with Curves.elasticOut instead of animateWith?A curve maps a fixed `duration` onto 0..1 and always starts from zero speed, so the release velocity is thrown away: a flick and a slow drop animate identically, and the card visibly stalls at release. `animateWith` with a `SpringSimulation` takes the velocity as an initial condition and lets the spring decide how long the motion lasts.
- What happens when the learner grabs the card again while it is still springing back?`onPanDown` calls `_controller.stop()`, which freezes the value and marks the pending `TickerFuture` as canceled. Because the listener has been writing each value into the card's `Alignment`, the new drag continues from wherever the card was. On release, a fresh tween and simulation start from that point.
- Does animateWith need the controller to have a duration?No. `animateWith` runs until the simulation's `isDone(time)` returns true, measuring time in seconds since the call. `duration` is only read by `forward`, `reverse`, `animateTo` and `repeat`, so a controller used purely for physics can be created without one.
Catching a ball that is already flying: a spring simulation starts from the ball's real speed and bends its path home, while forward() with a curve freezes the ball and restarts it from rest.
saying these in an interview costs you the question
- Pass the drag velocity in pixels per second straight into the simulation.
- animateWith still runs for the controller's duration, so one must be set.
- A default 0..1 controller will show the spring overshooting the centre.
- No need to stop the controller on touch; the new drag overrides it.
- A forward() call with an elastic curve keeps the release speed.