Animations & Motion Design

Explicit Animations: AnimationController & Tween

16 min Lesson 5 of 13

Explicit Animations: AnimationController & Tween

Flutter's animation system is divided into two families: implicit animations (like AnimatedContainer) that handle the controller for you, and explicit animations where you own the controller and drive every frame manually. Explicit animations give you precise control over playback direction, speed, looping, and sequencing — essential for anything beyond simple property transitions.

Why Use Explicit Animations?

Implicit widgets are convenient but limited. You need explicit animations when you want to:

  • Play an animation forward and then reverse on different user events
  • Repeat an animation indefinitely or a fixed number of times
  • Synchronise multiple animations to a single timeline
  • React to animation status events (started, completed, reversed, dismissed)
  • Drive a physics simulation or velocity-based curve

The TickerProviderStateMixin

An AnimationController requires a Ticker — a mechanism that calls a callback on every display refresh (typically 60 or 120 times per second). The mixin TickerProviderStateMixin makes your State class a valid TickerProvider. If you only ever create one controller, you may use SingleTickerProviderStateMixin instead, which is slightly more efficient.

Note: Always use SingleTickerProviderStateMixin when you have a single AnimationController. Use TickerProviderStateMixin when you manage two or more controllers in the same State.

Creating an AnimationController

The AnimationController is the master clock for an animation. You create it in initState, provide a duration, and pass vsync: this to bind it to the screen's ticker.

Minimal AnimationController Setup

import 'package:flutter/material.dart';

class FadeBox extends StatefulWidget {
  const FadeBox({super.key});

  @override
  State<FadeBox> createState() => _FadeBoxState();
}

class _FadeBoxState extends State<FadeBox>
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 800),
      vsync: this, // binds the controller to this State's Ticker
    );
  }

  @override
  void dispose() {
    _controller.dispose(); // ALWAYS dispose to prevent memory leaks
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(width: 100, height: 100, color: Colors.blue),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () => _controller.forward(),
              child: const Text('Play'),
            ),
          ],
        ),
      ),
    );
  }
}
Warning: Forgetting to call _controller.dispose() in dispose() causes a memory leak — the Ticker keeps running even after the widget is removed from the tree. This will eventually crash your app in debug mode with a "ticker was still active" error.

Attaching a Tween

An AnimationController alone only produces values between 0.0 and 1.0. A Tween maps that range onto any typed interval you need — a double, a Color, an Offset, a Size, and so on. You attach it with .animate() to produce a typed Animation<T>:

Tween Producing a Typed Animation

// Produces an Animation<double> going from 0.0 to 300.0
final Animation<double> _widthAnimation = Tween<double>(
  begin: 0.0,
  end: 300.0,
).animate(
  CurvedAnimation(
    parent: _controller,
    curve: Curves.easeInOut, // apply an easing curve
  ),
);

// Produces an Animation<Color?> transitioning between two colours
final Animation<Color?> _colorAnimation = ColorTween(
  begin: Colors.blue,
  end: Colors.orange,
).animate(_controller);

// Use the animated value in build():
@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _controller,
    builder: (context, child) {
      return Container(
        width: _widthAnimation.value,
        height: 80,
        color: _colorAnimation.value,
        child: child, // child is NOT rebuilt on every frame
      );
    },
    child: const Center(child: Text('Animate me')),
  );
}

Driving Forward, Reverse, and Repeat

Once you have a controller, you drive it with three key methods:

  • _controller.forward() — plays from the current value toward 1.0
  • _controller.reverse() — plays from the current value back toward 0.0
  • _controller.repeat(reverse: true) — loops the animation, alternating direction each cycle
  • _controller.reset() — snaps the value back to 0.0 without animating
  • _controller.stop() — freezes playback at the current value
Tip: You can listen to animation status changes using _controller.addStatusListener((status) { ... }). The four statuses are AnimationStatus.forward, dismissed, reverse, and completed. This is the correct way to chain animations or trigger actions when an animation finishes.

Rebuilding the UI with AnimatedBuilder

Wrapping your animated subtree in an AnimatedBuilder is the recommended pattern. It calls builder on every tick, but accepts a child parameter for parts of the subtree that do not change — those are built once and reused, keeping the per-frame cost minimal.

Summary

Explicit animations in Flutter require three steps: (1) mix in SingleTickerProviderStateMixin (or TickerProviderStateMixin) on your State; (2) create an AnimationController in initState and dispose it in dispose; (3) attach a Tween via .animate() to map the 0–1 range to your desired typed value. Drive the animation with forward(), reverse(), or repeat() in response to user events, and rebuild the UI with AnimatedBuilder for optimal performance.