Flutter: Full Control with Explicit Animations - AnimationController Guide
Implicit animations are great, but sometimes you need more control. If you want to start, stop, reverse, or change the speed of an animation at will, Explicit Animations come into play.
AnimationController is the heart of Flutter's animation engine. It's like an orchestra conductor that produces values for every frame, which you can direct as you wish.
Implicit vs Explicit: Which One When?
A quick reminder:
| Feature | Implicit | Explicit |
|---|---|---|
| Usage | Automatic with setState | Manual control |
| Code Amount | Little (3-5 lines) | More (15-30 lines) |
| Control | Limited | Full control |
| Start/Stop | Automatic | Manual |
| Use Case | Simple transitions | Complex animations |
| Example | AnimatedContainer | AnimationController |
When Should You Use Explicit?
- When you want to start the animation on button press
- When you want to run the animation in an infinite loop
- When you want to synchronize multiple animations
- When you want to control the animation based on user input (e.g., based on scroll distance)
- When you want to reverse the animation
AnimationController Basics
AnimationController produces a continuously increasing value from 0.0 to 1.0 (or any range you want).
Basic Usage
class BasicAnimationDemo extends StatefulWidget {
@override
_BasicAnimationDemoState createState() => _BasicAnimationDemoState();
}
class _BasicAnimationDemoState extends State<BasicAnimationDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this, // TickerProvider required
);
}
@override
void dispose() {
_controller.dispose(); // To prevent memory leaks!
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159, // 360 degrees
child: child,
);
},
child: Icon(Icons.star, size: 100, color: Colors.amber),
),
SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => _controller.forward(),
child: Text('Start'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () => _controller.reverse(),
child: Text('Reverse'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () => _controller.reset(),
child: Text('Reset'),
),
],
),
],
);
}
}Live Demo
You can try explicit animations in the interactive example below:
π‘ If the example above doesn't load, click DartPad to run it in a new tab.
What is TickerProvider?
The vsync parameter is required. So what does this TickerProvider do?
Why Is It Needed?
Flutter tries to draw 60 frames per second (60 FPS). AnimationController produces a new value for each frame. However, if the widget is not visible on screen (e.g., you navigated to another page), the animation unnecessarily consumes CPU.
vsync automatically stops the animation when the widget is not on screen. This results in:
- Battery savings
- CPU savings
- Performance improvement
Which Mixin Should You Use?
// If there's a single animation
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
}
// If there are multiple animations
class _MyWidgetState extends State<MyWidget>
with TickerProviderStateMixin {
late AnimationController _controller1;
late AnimationController _controller2;
late AnimationController _controller3;
}AnimationController Methods
Think of AnimationController like a VCR (video cassette player):
1. forward() - Play
Runs the animation from start to end (0.0 β 1.0):
_controller.forward();
// Start from a specific value
_controller.forward(from: 0.5); // Start from 0.52. reverse() - Rewind
Runs the animation from end to start (1.0 β 0.0):
_controller.reverse();
// Reverse from a specific value
_controller.reverse(from: 0.8);3. reset() - Reset
Returns the animation to 0.0 (animation doesn't run):
_controller.reset();4. stop() - Stop
Stops the animation at its current value:
_controller.stop();5. repeat() - Infinite Loop
Repeats the animation continuously:
// Infinite loop (0 β 1 β 0 β 1 ...)
_controller.repeat();
// With reverse (0 β 1 β 0 β 1 ...)
_controller.repeat(reverse: true);
// Repeat a specific number of times
_controller.repeat(reverse: true, period: Duration(seconds: 1));6. animateTo() - Go to Specific Value
Animates from current value to a specific value:
_controller.animateTo(0.7); // Go from current value to 0.7
// With custom duration
_controller.animateTo(
0.5,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
);7. animateBack() - Go Back
Animates backward from current value to a specific value:
_controller.animateBack(0.3);Tween: Value Transformation
AnimationController only produces values between 0.0 - 1.0. What if we want values between 0 - 360 (degrees) or 50 - 200 (pixels)?
Tween (short for between) transforms values.
Basic Tween Usage
class TweenDemo extends StatefulWidget {
@override
_TweenDemoState createState() => _TweenDemoState();
}
class _TweenDemoState extends State<TweenDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _sizeAnimation;
late Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
);
// Size animation: 50 β 200
_sizeAnimation = Tween<double>(
begin: 50,
end: 200,
).animate(_controller);
// Color animation: Red β Blue
_colorAnimation = ColorTween(
begin: Colors.red,
end: Colors.blue,
).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: _sizeAnimation.value,
height: _sizeAnimation.value,
decoration: BoxDecoration(
color: _colorAnimation.value,
borderRadius: BorderRadius.circular(20),
),
);
},
),
SizedBox(height: 40),
ElevatedButton(
onPressed: () {
if (_controller.status == AnimationStatus.completed) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: Text('Run Animation'),
),
],
);
}
}Common Tween Types
// Number (double)
Tween<double>(begin: 0, end: 100)
// Number (int)
IntTween(begin: 0, end: 255)
// Color
ColorTween(begin: Colors.red, end: Colors.blue)
// Offset (position)
Tween<Offset>(begin: Offset.zero, end: Offset(1.0, 0.0))
// Size
Tween<Size>(begin: Size(50, 50), end: Size(200, 200))
// BorderRadius
Tween<BorderRadius>(
begin: BorderRadius.circular(0),
end: BorderRadius.circular(50),
)
// TextStyle
TextStyleTween(
begin: TextStyle(fontSize: 14),
end: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
)CurvedAnimation: Adding Character to Animation
Tween transforms values, CurvedAnimation determines the character of the animation:
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
);
// Apply curve
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut, // Elastic effect
);
// Use with Tween
_sizeAnimation = Tween<double>(
begin: 50,
end: 200,
).animate(curvedAnimation);Different Curves
// Smooth in-out
Curves.easeInOut
// Elastic (like a spring)
Curves.elasticOut
// Bounce
Curves.bounceOut
// Overshoot
Curves.anticipate
// Acceleration
Curves.accelerate
// Deceleration
Curves.decelerateAnimatedBuilder: Performance-Friendly Rebuild
AnimatedBuilder rebuilds only the necessary widget:
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// This part rebuilds every frame
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: child, // This doesn't rebuild!
);
},
child: Icon(Icons.star, size: 100), // Static, cached
)Performance Tip: The child parameter is used for widgets that don't change during animation. Flutter caches it and doesn't recreate it every frame.
Animation Status: Listening to State
You can track the status of the animation:
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
print('Animation completed!');
_controller.reverse(); // Auto reverse
} else if (status == AnimationStatus.dismissed) {
print('Animation back to start!');
}
});
}Status Types
AnimationStatus.forward // Going forward (0 β 1)
AnimationStatus.reverse // Going backward (1 β 0)
AnimationStatus.completed // Completed (at 1.0)
AnimationStatus.dismissed // Back to start (at 0.0)Real World Examples
1. Infinite Rotation (Loading Spinner)
class LoadingSpinner extends StatefulWidget {
@override
_LoadingSpinnerState createState() => _LoadingSpinnerState();
}
class _LoadingSpinnerState extends State<LoadingSpinner>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
)..repeat(); // Infinite loop
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: child,
);
},
child: Icon(Icons.refresh, size: 50, color: Colors.blue),
),
);
}
}2. Pulsing Heart Animation
class PulsingHeart extends StatefulWidget {
@override
_PulsingHeartState createState() => _PulsingHeartState();
}
class _PulsingHeartState extends State<PulsingHeart>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 800),
vsync: this,
)..repeat(reverse: true); // Back and forth
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.2,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child,
);
},
child: Icon(Icons.favorite, size: 100, color: Colors.red),
),
);
}
}3. Fade In and Slide Up Combination
class FadeSlideIn extends StatefulWidget {
final Widget child;
FadeSlideIn({required this.child});
@override
_FadeSlideInState createState() => _FadeSlideInState();
}
class _FadeSlideInState extends State<FadeSlideIn>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 600),
vsync: this,
);
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_slideAnimation = Tween<Offset>(
begin: Offset(0, 0.3), // From below
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: _opacityAnimation,
child: SlideTransition(
position: _slideAnimation,
child: child,
),
);
},
child: widget.child,
);
}
}
// Usage:
FadeSlideIn(
child: Text('Hello World!', style: TextStyle(fontSize: 32)),
)4. Progress Bar Animation
class AnimatedProgressBar extends StatefulWidget {
final double progress; // 0.0 - 1.0
AnimatedProgressBar({required this.progress});
@override
_AnimatedProgressBarState createState() => _AnimatedProgressBarState();
}
class _AnimatedProgressBarState extends State<AnimatedProgressBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _progressAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 500),
vsync: this,
);
_progressAnimation = Tween<double>(
begin: 0,
end: widget.progress,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward();
}
@override
void didUpdateWidget(AnimatedProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.progress != oldWidget.progress) {
_progressAnimation = Tween<double>(
begin: _progressAnimation.value,
end: widget.progress,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 20,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
child: AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: _progressAnimation.value,
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
),
);
},
),
);
}
}5. Shake Animation
class ShakeWidget extends StatefulWidget {
final Widget child;
final bool shake;
ShakeWidget({required this.child, this.shake = false});
@override
_ShakeWidgetState createState() => _ShakeWidgetState();
}
class _ShakeWidgetState extends State<ShakeWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _shakeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 500),
vsync: this,
);
_shakeAnimation = Tween<double>(
begin: 0,
end: 10,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticIn,
));
}
@override
void didUpdateWidget(ShakeWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.shake && !oldWidget.shake) {
_controller.forward(from: 0).then((_) => _controller.reverse());
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shakeAnimation.value * (_controller.value > 0.5 ? -1 : 1), 0),
child: child,
);
},
child: widget.child,
);
}
}
// Usage:
class LoginScreen extends StatefulWidget {
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _showError = false;
void _login() {
// Failed login
setState(() => _showError = true);
Future.delayed(Duration(milliseconds: 500), () {
setState(() => _showError = false);
});
}
@override
Widget build(BuildContext context) {
return ShakeWidget(
shake: _showError,
child: TextField(
decoration: InputDecoration(
labelText: 'Password',
errorText: _showError ? 'Wrong password!' : null,
),
),
);
}
}Synchronizing Multiple Animations
Multiple animations with a single controller:
class MultiAnimationDemo extends StatefulWidget {
@override
_MultiAnimationDemoState createState() => _MultiAnimationDemoState();
}
class _MultiAnimationDemoState extends State<MultiAnimationDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
late Animation<double> _scaleAnimation;
late Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 3),
vsync: this,
);
// Rotation during 0.0 - 0.5
_rotationAnimation = Tween<double>(
begin: 0,
end: 2 * 3.14159,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.5, curve: Curves.easeInOut),
));
// Scaling during 0.5 - 1.0
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 2.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.5, 1.0, curve: Curves.easeOut),
));
// Color change during 0.0 - 1.0
_colorAnimation = ColorTween(
begin: Colors.blue,
end: Colors.red,
).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: _colorAnimation.value,
borderRadius: BorderRadius.circular(20),
),
),
),
);
},
),
SizedBox(height: 40),
ElevatedButton(
onPressed: () {
if (_controller.status == AnimationStatus.completed) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: Text('Start Animation'),
),
],
);
}
}TweenSequence: Step-by-Step Animation
Different values at different stages:
final _colorAnimation = TweenSequence<Color?>([
TweenSequenceItem(
tween: ColorTween(begin: Colors.red, end: Colors.blue),
weight: 33.3, // 33.3% portion
),
TweenSequenceItem(
tween: ColorTween(begin: Colors.blue, end: Colors.green),
weight: 33.3,
),
TweenSequenceItem(
tween: ColorTween(begin: Colors.green, end: Colors.red),
weight: 33.4,
),
]).animate(_controller);Performance Tips
1. Don't Forget dispose()
@override
void dispose() {
_controller.dispose(); // Mandatory!
super.dispose();
}If you forget, you'll have a memory leak.
2. Use AnimatedBuilder
// β Bad - Entire widget rebuilds
@override
Widget build(BuildContext context) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: ExpensiveWidget(),
);
}
// β
Good - Only necessary part rebuilds
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: child,
);
},
child: ExpensiveWidget(), // Cached
);
}3. Don't Animate Unnecessarily
// β 100 widgets animating at once
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: 1.0 + _controller.value * 0.2,
child: ListTile(title: Text('Item $index')),
);
},
);
},
)
// β
Good - Only visible ones should animate (lazy loading)4. Cache const Widgets
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: child,
);
},
child: const Icon(Icons.star, size: 100), // const!
)Common Mistakes and Solutions
Error 1: Forgetting vsync
// β Error: vsync required
_controller = AnimationController(
duration: Duration(seconds: 2),
);
// β
Correct
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this, // this is TickerProvider
);
}
}Error 2: Forgetting dispose()
// β Memory leak!
@override
void dispose() {
super.dispose();
}
// β
Correct
@override
void dispose() {
_controller.dispose();
super.dispose();
}Error 3: Wrong Mixin Choice
// β 3 animations but Single is used
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller1;
late AnimationController _controller2;
late AnimationController _controller3; // Error!
}
// β
Correct
class _MyWidgetState extends State<MyWidget>
with TickerProviderStateMixin {
late AnimationController _controller1;
late AnimationController _controller2;
late AnimationController _controller3;
}Error 4: Duration Too Long
// β 10 seconds is too long
AnimationController(
duration: Duration(seconds: 10),
vsync: this,
)
// β
2-3 seconds is ideal
AnimationController(
duration: Duration(seconds: 2),
vsync: this,
)Summary
- AnimationController: Control mechanism that produces values between 0.0 - 1.0
- vsync: Automatically stops animations not on screen (battery saving)
- Tween: Value transformation (0-1 β 50-200, etc.)
- CurvedAnimation: Adds character to animation (easeInOut, elasticOut, etc.)
- AnimatedBuilder: Rebuilds only necessary widget for performance
- Status Listener: Listens to animation state (completed, dismissed, etc.)
- Methods: forward(), reverse(), repeat(), reset(), stop()
- Mixin: SingleTickerProviderStateMixin (1 animation) or TickerProviderStateMixin (multiple)
- dispose(): Mandatory to prevent memory leaks
- Performance: Use const child, AnimatedBuilder
Explicit animations give you full control over animations in Flutter. Although the learning curve is a bit steeper, the impressive animations you'll create are worth it!