Skip to content
Blog

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.

Further reading