Flutter Lesson 55 of 83 4 min read
Explicit Animations in Flutter: AnimationController and Tween
Control animations precisely in Flutter with AnimationController, Tween, CurvedAnimation, AnimatedBuilder and staggered sequences.
On this page
An explicit animation gives you the controls: play, stop, reverse, repeat, jump to a position. You need one when an animation loops, responds to a drag, or coordinates several properties.
The pieces #
| Piece | Role |
|---|---|
AnimationController | The engine. Produces a value from 0.0 to 1.0 over a duration, and has forward(), reverse(), repeat() and stop() |
Ticker (via a mixin) | Calls the controller once per frame, and pauses when the widget is not visible |
Tween | Maps 0.0 to 1.0 onto the range you want: sizes, colours, offsets |
CurvedAnimation | Applies a curve |
A transition or AnimatedBuilder | Rebuilds the UI on every tick |
A pulsing heart #
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: Scaffold(body: Center(child: PulsingHeart()))));
class PulsingHeart extends StatefulWidget {
const PulsingHeart({super.key});
@override
State<PulsingHeart> createState() => _PulsingHeartState();
}
class _PulsingHeartState extends State<PulsingHeart>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
)..repeat(reverse: true);
_scale = Tween<double>(begin: 0.85, end: 1.15).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scale,
child: const Icon(Icons.favorite, color: Colors.red, size: 96),
);
}
}
Three things you must always do:
- Add
with SingleTickerProviderStateMixinto the state class. It suppliesvsync: this. - Create the controller in
initState. - Dispose it in
dispose. A forgotten controller keeps ticking for ever.
If one state needs several controllers, use TickerProviderStateMixin.
Controlling playback #
_controller.forward(); // 0 to 1
_controller.reverse(); // 1 to 0
_controller.repeat(); // loop
_controller.repeat(reverse: true); // back and forth
_controller.stop();
_controller.reset(); // jump to 0
_controller.value = 0.5; // jump anywhere
_controller.animateTo(0.8); // animate to a point
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) debugPrint('Finished');
});
Ready-made transitions #
These take an animation and apply it to a child, rebuilding efficiently.
| Widget | Animates | Takes |
|---|---|---|
FadeTransition | Opacity | Animation<double> |
ScaleTransition | Scale | Animation<double> |
RotationTransition | Rotation, in turns | Animation<double> |
SlideTransition | Position, as a fraction of its size | Animation<Offset> |
SizeTransition | Clipped size | Animation<double> |
SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
),
child: const Card(child: Padding(padding: EdgeInsets.all(24), child: Text('Slides up'))),
)
AnimatedBuilder: anything else #
When no transition fits, AnimatedBuilder rebuilds whatever you return on every tick.
import 'dart:math' as math;
AnimatedBuilder(
animation: _controller,
child: const Icon(Icons.settings, size: 64), // built once, not every frame
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * math.pi,
child: child,
);
},
)
Pass the unchanging part as child. It is built once and handed back to the builder, which avoids rebuilding it sixty times a second.
Playing on a tap #
class _ExpandButtonState extends State<ExpandButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
void _toggle() {
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: _toggle,
icon: AnimatedIcon(icon: AnimatedIcons.menu_close, progress: _controller),
);
}
}
A late final field with an initializer is created the first time it is used, which is a tidy alternative to initState.
Staggered animations #
One controller can drive several animations that start at different moments. Give each an Interval: a slice of the controller’s 0 to 1 range.
late final Animation<double> _fade = CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut), // first half
);
late final Animation<Offset> _slide = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.2, 1.0, curve: Curves.easeOut), // overlaps, ends later
));
// In build
FadeTransition(
opacity: _fade,
child: SlideTransition(position: _slide, child: const WelcomeCard()),
)
Driving an animation with a gesture #
Because controller.value can be set directly, a drag can control an animation frame by frame.
GestureDetector(
onHorizontalDragUpdate: (details) {
_controller.value += details.primaryDelta! / 300; // 300 px for the full range
},
onHorizontalDragEnd: (_) {
_controller.value > 0.5 ? _controller.forward() : _controller.reverse();
},
child: ...,
)
Implicit or explicit? #
| Question | If yes |
|---|---|
| Does it just go from one value to another when state changes? | Implicit |
| Does it repeat or loop? | Explicit |
| Do you need to pause, reverse or seek? | Explicit |
| Is it tied to a gesture? | Explicit |
| Are several properties sequenced? | Explicit, with intervals |
For rich pre-designed animations, such as an animated illustration or a character, use the lottie or rive packages, which play files made by designers.
Try it yourself #
Build a loading indicator from three dots that bounce one after another, using one repeating controller and three Intervals. Then build a card that fades and slides in when the screen opens.