Explicit Animations: AnimationController & Tween
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.
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'),
),
],
),
),
);
}
}
_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
_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.