Animation
Implicit animations for the common cases, explicit animations when you need control, and the habits that keep animation off the critical path.
Contents
- Choosing an approach
- Implicit animations
- Explicit animations
- Tweens and curves
- Transition widgets
- Staggered animations
- Hero transitions
- Physics-based motion
- Performance
Choosing an approach
| Need | Use |
|---|---|
| Animate to a new value when state changes | Implicit — AnimatedContainer, AnimatedOpacity, AnimatedAlign |
| Animate an arbitrary value implicitly | TweenAnimationBuilder |
| Swap one widget for another | AnimatedSwitcher |
| Repeat, reverse, pause, or sequence | Explicit — AnimationController |
| Drive animation from a gesture | Explicit, driving the controller's value directly |
| Move an element between routes | Hero |
| Motion that should feel physical | SpringSimulation via controller.animateWith |
Start implicit. Reach for a controller when you need to control the animation rather than just declare its endpoint.
Implicit animations
Change a property and the widget animates to it. No controller, no disposal.
AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
width: _expanded ? 280 : 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _expanded ? scheme.primaryContainer : scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(_expanded ? 24 : 12),
),
child: const Text('Details'),
)
TweenAnimationBuilder animates any value that has a Tween:
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress),
duration: const Duration(milliseconds: 400),
builder: (context, value, child) =>
LinearProgressIndicator(value: value),
)
AnimatedSwitcher cross-fades between children. It compares by widget key,
so give the children distinct keys or the switch will not be detected:
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: isLoading
? const CircularProgressIndicator(key: ValueKey('loading'))
: ResultView(key: ValueKey(result.id), result: result),
)
Explicit animations
An AnimationController produces a double from 0 to 1 over a duration, driven
by a Ticker that fires once per frame.
class Pulse extends StatefulWidget {
const Pulse({super.key, required this.child});
final Widget child;
@override
State<Pulse> createState() => _PulseState();
}
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
late final Animation<double> _scale = Tween(begin: 1.0, end: 1.08)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
@override
void dispose() {
_controller.dispose(); // mandatory — a leaked ticker runs forever
super.dispose();
}
@override
Widget build(BuildContext context) =>
ScaleTransition(scale: _scale, child: widget.child);
}
Three rules:
vsyncmust be aTickerProvider. UseSingleTickerProviderStateMixinfor one controller,TickerProviderStateMixinfor several.- Always
dispose()the controller. A ticker that outlives its widget keeps producing frames and holds theStatein memory. - Controller methods:
forward,reverse,repeat,stop,reset,animateTo,fling. Each returns aTickerFutureyou can await.
Tweens and curves
A Tween maps the controller's 0-to-1 range onto real values; a curve reshapes
time.
final offset = Tween<Offset>(begin: const Offset(0, 0.15), end: Offset.zero)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
final colour = ColorTween(begin: Colors.grey, end: Colors.indigo)
.animate(_controller);
Useful curves: easeOut for entrances, easeIn for exits, easeInOut for
moves, easeOutBack for a slight overshoot, Curves.fastOutSlowIn for the
Material default. Avoid linear for anything that represents physical motion.
CurveTween chains with Tween when you want the curve applied before the
value mapping, which matters for staggering.
Transition widgets
Prefer the built-in *Transition widgets over AnimatedBuilder where one
exists — they rebuild only themselves, not your subtree.
| Widget | Animates |
|---|---|
FadeTransition | Opacity |
ScaleTransition | Scale |
SlideTransition | Fractional offset |
RotationTransition | Rotation |
SizeTransition | Extent along one axis |
DecoratedBoxTransition | Decoration |
AlignTransition | Alignment |
For anything else, AnimatedBuilder with the child argument keeps the
expensive subtree out of the rebuild:
AnimatedBuilder(
animation: _controller,
// built once, not per frame
child: const ExpensiveContent(),
builder: (context, child) => Transform.rotate(
angle: _controller.value * math.pi,
child: child,
),
)
Staggered animations
Drive several animations from one controller using Interval, so they stay in
sync and cost one ticker.
Animation<double> _step(double begin, double end) => CurvedAnimation(
parent: _controller,
curve: Interval(begin, end, curve: Curves.easeOutCubic),
);
late final _fade = _step(0.0, 0.4);
late final _slide = Tween(begin: const Offset(0, 0.2), end: Offset.zero)
.animate(_step(0.2, 0.7));
Hero transitions
Wrap the same tag on both routes and the framework animates the element
between them:
// List page
Hero(tag: 'product-${product.id}', child: ProductThumb(product: product))
// Detail page
Hero(tag: 'product-${product.id}', child: ProductImage(product: product))
Tags must be unique within a route. If the two widgets differ in shape, supply a
flightShuttleBuilder to control what is drawn mid-flight. Custom route
transitions are covered in
Navigation and routing.
Physics-based motion
For motion that continues after a gesture ends, drive the controller with a simulation instead of a duration:
void _settle(double velocity) {
final simulation = SpringSimulation(
const SpringDescription(mass: 1, stiffness: 500, damping: 30),
_controller.value,
0, // target
velocity,
);
_controller.animateWith(simulation);
}
This is what makes a dismissible sheet feel right: the animation inherits the velocity of the finger that threw it.
Performance
- Animate the cheapest property. Transform and opacity are handled by the compositor; animating layout properties such as width forces relayout of the subtree every frame.
- Pass
childto builders so the static part is built once. - Add a
RepaintBoundaryaround an animating subtree so its repaints do not dirty the rest of the screen — but not around everything, since each boundary costs a layer. - Avoid
Opacityfor animation;FadeTransitionandAnimatedOpacityuse cheaper paths. A plainOpacitybetween 0 and 1 triggerssaveLayer. - Do not animate while offscreen. Stop controllers when a route is not visible or the app is backgrounded — see Lifecycle.
- Measure in profile mode. Debug builds are not representative; use
flutter run --profileand the frame chart in DevTools and performance.
Next: Navigation and routing.