Physics-Based Animations: Springs, Friction & Simulations
Physics-Based Animations: Springs, Friction & Simulations
Most Flutter animations use a fixed Duration and an Curve to map time to a value. Physics-based animations work differently: they are driven by simulated forces rather than a predetermined timeline. The motion ends when the simulated system reaches equilibrium, not when a clock runs out. This produces the natural, organic feel found in high-quality mobile apps.
Flutter exposes physics simulations through the AnimationController.animateWith(Simulation) method. Instead of calling forward() or reverse(), you hand the controller a Simulation object that computes position and velocity at every tick. The two most useful built-in simulations are SpringSimulation and FrictionSimulation.
SpringSimulation
A SpringSimulation models a damped harmonic oscillator — the classic mass-on-a-spring. You define its behaviour through a SpringDescription, which takes three named parameters:
- mass — the simulated mass attached to the spring (heavier = more inertia, slower response).
- stiffness — how tightly the spring pulls toward the target (higher = snappier).
- damping — resistance that bleeds energy (higher = less oscillation; >= 2×√(stiffness×mass) = over-damped, no bounce).
SpringSimulation Example — Bouncing Card
import 'package:flutter/physics.dart';
class SpringCard extends StatefulWidget {
const SpringCard({super.key});
@override
State<SpringCard> createState() => _SpringCardState();
}
class _SpringCardState extends State<SpringCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this)
..addListener(() => setState(() {}));
// Lightly damped spring: will oscillate a few times before settling.
final spring = SpringDescription(
mass: 1.0,
stiffness: 200.0,
damping: 10.0,
);
final simulation = SpringSimulation(
spring,
0.0, // start position
1.0, // end position
0.0, // initial velocity
);
_controller.animateWith(simulation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Transform.scale(
scale: 0.8 + 0.2 * _controller.value,
child: Card(
color: Colors.deepPurple,
child: const SizedBox(width: 200, height: 120,
child: Center(
child: Text('Spring!',
style: TextStyle(color: Colors.white, fontSize: 24)),
),
),
),
);
}
}
FrictionSimulation
A FrictionSimulation models a body decelerating under friction — like a card flicked across a surface and gradually stopping. You supply a drag coefficient (how strongly friction acts), the starting position, and the initial velocity. The body coasts to a stop with no target endpoint; the final resting position is determined by physics alone.
FrictionSimulation Example — Flick-to-Dismiss Panel
import 'package:flutter/physics.dart';
class FrictionPanel extends StatefulWidget {
const FrictionPanel({super.key});
@override
State<FrictionPanel> createState() => _FrictionPanelState();
}
class _FrictionPanelState extends State<FrictionPanel>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
// AnimationController with no duration; physics drives it.
_controller = AnimationController(vsync: this)
..addListener(() => setState(() {}));
}
void _onFlingEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0.0;
if (velocity.abs() < 50) return;
// Normalise velocity to [0, 1] range — controller value is 0..1.
final normalised = velocity / 1000.0;
final simulation = FrictionSimulation(
0.135, // drag coefficient (higher = stops faster)
_controller.value,
normalised, // initial velocity in units per second
);
_controller.animateWith(simulation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragEnd: _onFlingEnd,
child: Transform.translate(
offset: Offset(_controller.value * 300, 0),
child: Container(
width: 280, height: 80,
color: Colors.teal,
alignment: Alignment.center,
child: const Text('Flick me!',
style: TextStyle(color: Colors.white)),
),
),
);
}
}
Choosing the Right Simulation
- Use SpringSimulation when you want an element to snap to a target position with optional bounce — ideal for modals, cards, or bottom sheets appearing on screen.
- Use FrictionSimulation when you want motion driven by a flick or drag gesture that coasts naturally to a stop — ideal for scrollable surfaces or swipe-to-dismiss interactions.
- Combine both by chaining: catch a fling with
FrictionSimulation, then snap to a grid position withSpringSimulation.
AnimationController(lowerBound: 0.0, upperBound: 1.0, vsync: this) when you need the simulation to stay within a safe range. Without bounds, a fast spring or fling can drive the value well past 1.0 or below 0.0 and break layout transforms.Tolerances and When a Simulation Ends
A simulation signals completion via isDone(double time). Flutter uses a default Tolerance object (from physics.dart) with distance = 0.001 and velocity = 0.001. You can pass a custom Tolerance to SpringSimulation as the optional fifth argument if you need the animation to finish earlier (looser tolerance) or settle more precisely (tighter tolerance).
animateWith() while the controller is already running another simulation without first calling stop(). Overlapping calls corrupt the internal clock reference and can cause jank or an assertion error in debug mode.Summary
Physics-based animations in Flutter replace fixed-duration curves with Simulation objects driven by forces like spring stiffness, damping, and friction. AnimationController.animateWith() is the entry point. SpringSimulation targets a specific endpoint and can oscillate; FrictionSimulation coasts from an initial velocity with no fixed endpoint. Together they enable interactions that feel as natural and responsive as real-world physical objects.