Explicit Animations in Flutter: Mastering the AnimationController
When you need to drive, pause, reverse, or sequence an animation, implicit widgets are not enough. Learn the AnimationController, how AnimatedBuilder rebuilds efficiently, and when to step up from implicit motion.
Published on • October 21, 2026
AI Assistant

Implicit animations are the right default: change a value, get motion, move on. But they run one-shot and uncontrollable. The moment you need an animation you can start on demand, pause halfway, reverse, repeat, or chain into the next one, you have outgrown them — and that is the door to explicit animation, built around the AnimationController.
Source: Animations in Flutter — API overview and the animations tutorial, The Flutter Team, docs.flutter.dev.
The controller is a clock
An AnimationController is a source of values over time. Given a duration and a vsync (the ticker that ties it to the screen’s frame rate), it produces a stream of values — by default from 0.0 to 1.0 — that you map onto whatever you want to animate. In a StatefulWidget it belongs in initState, and it must be disposed:
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// ...
}
The SingleTickerProviderStateMixin supplies the vsync. That mixin is what synchronizes your animation to the display and prevents off-screen work.
Driving it
Once you have a controller, you command playback explicitly — the whole reason to come here:
_controller.forward(); // run 0.0 -> 1.0
_controller.reverse(); // run back to 0.0
_controller.repeat(); // loop
_controller.stop(); // halt in place
To turn the controller’s normalized output into a useful value, wrap it in a Tween. A Tween maps the controller’s 0..1 onto any range you care about:
final _scale = Tween<double>(begin: 1.0, end: 1.2).animate(_controller);
And CurvedAnimation reshapes the timing so motion eases rather than moving linearly.
AnimatedBuilder: rebuild only what matters
With a controller and a tween in hand, the question is how to turn values into widget rebuilds without rebuilding your entire subtree every frame. That is AnimatedBuilder’s job. It listens to the animation and rebuilds only its builder closure:
AnimatedBuilder(
animation: _scale,
builder: (context, child) => Transform.scale(
scale: _scale.value,
child: child,
),
child: const MyExpensiveContent(), // built once, reused every frame
)
The child parameter is the performance detail that separates good explicit animation from jank. Anything that does not change across frames — here, MyExpensiveContent — is built once and passed through, so each tick only recomputes the transformed wrapper. Rebuilding expensive content on every frame is the classic explicit-animation mistake.
When to step up from implicit
The dividing line is control. Stay with implicit widgets when a single value change should trigger a self-contained transition you never need to touch again. Move to an AnimationController when you need any of:
- Triggered playback — the animation starts on a user action, not on a value change.
- Playback control — pause, resume, reverse, or stop mid-flight.
- Sequencing and staggering — chain multiple animations or offset their starts (see staggered animations).
- Repeat and loop — pulsing, spinning, or breathing indicators.
- Physics-driven motion — simulations where velocity and spring behavior matter.
The two are not mutually exclusive. A common,idiomatic pattern is to reach for an implicit widget for simple state changes and reserve controllers for the handful of moments that genuinely need orchestration — which keeps your codebase free of controllers you did not need.
Key takeaways
AnimationController gives you a frame-synced clock you command with forward, reverse, repeat, and stop — the control implicit widgets cannot offer. Map its normalized output through a Tween (and optionally a CurvedAnimation) to animate any value, and render with AnimatedBuilder, passing stable content via child so you rebuild only what changes each frame. Use explicit animation when motion must be triggered, controlled, sequenced, or looped; otherwise the implicit family is still the better default.