Flutter
Flutter Animation System: Implicit, Explicit, and Custom Animations
Animations are what separate a functional app from one that feels alive. Flutter ships with one of the most powerful animation systems in mobile development, but its layered architecture can be overwhelming if you don't know where to start. After building apps where nearly every screen involved choreographed motion, I've developed a clear mental model for choosing the right animation approach every time.
The Animation Layer Cake
Flutter's animation system is built in layers. At the bottom sits the Ticker that fires on every vsync (usually 60 or 120 times per second). On top of that, AnimationController manages timing and value interpolation. Above that, Tween objects map ranges to concrete values. And at the very top, implicit animation widgets wrap all of that complexity into a single line of code.
Understanding these layers means you always know where to reach when the convenient option falls short.
Implicit Animations: The Easy Win
Implicit animations are Flutter's gift to developers who want motion without ceremony. You describe the target state, and the framework handles the transition.
AnimatedContainer
The workhorse of implicit animations. It interpolates between any change to its decoration, size, padding, margin, alignment, or transform.
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOutCubic,
width: _expanded ? 300 : 150,
height: _expanded ? 200 : 80,
decoration: BoxDecoration(
color: _expanded ? Colors.indigo : Colors.indigo.shade200,
borderRadius: BorderRadius.circular(_expanded ? 24 : 12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_expanded ? 0.3 : 0.1),
blurRadius: _expanded ? 20 : 8,
offset: Offset(0, _expanded ? 10 : 4),
),
],
),
child: Center(
child: Text(
_expanded ? 'Expanded' : 'Tap me',
style: const TextStyle(color: Colors.white, fontSize: 18),
),
),
),
);
}
}Other Implicit Widgets Worth Knowing
Each of these follows the same pattern: change a property, and the widget animates to it.
// Fade a widget in and out
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Text('Now you see me'),
)
// Slide a widget to a new position
AnimatedPositioned(
duration: const Duration(milliseconds: 500),
curve: Curves.elasticOut,
left: _moved ? 200 : 50,
top: _moved ? 100 : 50,
child: const CircleAvatar(radius: 30),
)
// Smoothly switch between two widgets
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: Text(
'$_count',
key: ValueKey<int>(_count),
style: const TextStyle(fontSize: 48),
),
)In apps with heavy animations, I've learned that implicit animations should be your first instinct. About 70% of the motion you need in a typical app can be achieved without ever touching an AnimationController.
Explicit Animations: Full Control
When you need precise timing, sequencing, or the ability to play, pause, reverse, or repeat an animation, you step into explicit animation territory.
The Core Trio: AnimationController, Tween, CurvedAnimation
class PulsingButton extends StatefulWidget {
const PulsingButton({super.key});
@override
State<PulsingButton> createState() => _PulsingButtonState();
}
class _PulsingButtonState extends State<PulsingButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
late final Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat(reverse: true);
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(curvedAnimation);
_colorAnimation = ColorTween(
begin: Colors.blue,
end: Colors.purple,
).animate(curvedAnimation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _colorAnimation.value,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
onPressed: () {},
child: child,
),
);
},
child: const Text('Subscribe', style: TextStyle(fontSize: 18)),
);
}
}Key points to remember: always use SingleTickerProviderStateMixin (or TickerProviderStateMixin for multiple controllers), always dispose your controllers, and always pass a static child to AnimatedBuilder when possible to avoid rebuilding the subtree on every frame.
TweenAnimationBuilder: The Middle Ground
When you want Tween-based control without managing an AnimationController, TweenAnimationBuilder is your friend.
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _targetAngle),
duration: const Duration(milliseconds: 800),
curve: Curves.easeOutBack,
builder: (context, angle, child) {
return Transform.rotate(
angle: angle,
child: child,
);
},
child: const Icon(Icons.refresh, size: 48),
)Staggered Animations: Choreographed Motion
Staggered animations use a single AnimationController with multiple Interval-based curves. Each element starts at a different point in the timeline, creating a cascading effect.
class StaggeredListAnimation extends StatefulWidget {
const StaggeredListAnimation({super.key});
@override
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
final int _itemCount = 5;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Animation<double> _buildSlideAnimation(int index) {
final start = index * 0.1;
final end = start + 0.4;
return Tween<double>(begin: 80.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(0.0, 1.0), curve: Curves.easeOutCubic),
),
);
}
Animation<double> _buildFadeAnimation(int index) {
final start = index * 0.1;
final end = start + 0.4;
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(0.0, 1.0), curve: Curves.easeOut),
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Column(
children: List.generate(_itemCount, (index) {
final slide = _buildSlideAnimation(index);
final fade = _buildFadeAnimation(index);
return Opacity(
opacity: fade.value,
child: Transform.translate(
offset: Offset(slide.value, 0),
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('Item ${index + 1}'),
),
),
);
}),
);
},
);
}
}The trick is to keep all intervals within the 0.0 to 1.0 range and overlap them slightly so the motion feels continuous rather than sequential.
Custom Animations with CustomPainter
When widgets can't express what you need, drop down to the canvas. CustomPainter lets you draw anything and animate it frame by frame.
class WaveAnimation extends StatefulWidget {
const WaveAnimation({super.key});
@override
State<WaveAnimation> createState() => _WaveAnimationState();
}
class _WaveAnimationState extends State<WaveAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
size: const Size(double.infinity, 200),
painter: WavePainter(progress: _controller.value),
);
},
);
}
}
class WavePainter extends CustomPainter {
final double progress;
WavePainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue.withOpacity(0.6)
..style = PaintingStyle.fill;
final path = Path()..moveTo(0, size.height * 0.5);
for (double x = 0; x <= size.width; x++) {
final y = size.height * 0.5 +
sin((x / size.width * 2 * pi) + (progress * 2 * pi)) * 30 +
sin((x / size.width * 4 * pi) + (progress * 4 * pi)) * 15;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.progress != progress;
}
}The shouldRepaint method is critical. Return true only when the data your painter depends on has actually changed. Returning true unconditionally forces a repaint every frame even when nothing moved, which wastes GPU time.
Hero Animations and Page Transitions
Hero animations create visual continuity between screens by morphing a shared element during navigation.
// Source screen
GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => DetailScreen(item: item)),
),
child: Hero(
tag: 'item-${item.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(item.imageUrl, width: 100, height: 100, fit: BoxFit.cover),
),
),
)
// Destination screen
Hero(
tag: 'item-${item.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(0),
child: Image.network(item.imageUrl, width: double.infinity, height: 300, fit: BoxFit.cover),
),
)Custom Page Transitions
For more control over how pages enter and exit, build your own PageRouteBuilder.
Route createFadeScaleRoute(Widget page) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeInOutCubic);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1.0).animate(curved),
child: child,
),
);
},
);
}
// Usage
Navigator.push(context, createFadeScaleRoute(const DetailScreen()));Animation Performance Tips
1. Use RepaintBoundary
When only part of the screen is animating, wrap it in a RepaintBoundary. This tells the engine to composite that subtree on its own layer, so the rest of the UI is not repainted.
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: child,
);
},
child: const Icon(Icons.sync, size: 64),
),
)2. Implement shouldRepaint Correctly
In CustomPainter, an incorrect shouldRepaint is a silent performance killer. Compare only the fields your paint method uses.
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.progress != progress;
// Do NOT always return true unless you know it changes every frame
}3. Prefer Transform Over Layout Changes
Transform.translate, Transform.scale, and Transform.rotate operate at the compositing layer. They do not trigger layout recalculations. By contrast, animating width, height, or padding forces a full layout pass.
4. Use the child Parameter in AnimatedBuilder
Anything that does not change during the animation should be passed as the child parameter. This subtree is built once and reused on every frame.
5. Avoid Opacity When Possible
The Opacity widget creates an offscreen buffer. For simple fade-ins, prefer FadeTransition with an explicit animation, or use AnimatedOpacity which internally optimizes this.
When to Use Which Animation Type
| Scenario | Recommended Approach |
|---|---|
| Property change on tap (color, size, border) | AnimatedContainer or other implicit widget |
| Fade or slide a widget in/out | AnimatedOpacity, AnimatedSlide |
| Switching between two widgets | AnimatedSwitcher |
| Looping or repeating animation | Explicit with AnimationController.repeat() |
| Precise play/pause/reverse control | Explicit with AnimationController |
| Multiple elements entering in sequence | Staggered animation with Interval |
| Drawing shapes, charts, particles | CustomPainter with AnimationController |
| Shared element between screens | Hero widget |
| Custom page enter/exit | PageRouteBuilder |
| Simple tween without controller boilerplate | TweenAnimationBuilder |
If you are unsure, start with an implicit animation. You can always upgrade to explicit control later without restructuring your widget.
Common Mistakes and Gotchas
1. Forgetting to Dispose Controllers
Every AnimationController must be disposed in dispose(). A leaked controller keeps its Ticker alive, which fires callbacks on a widget that no longer exists, leading to the dreaded "setState called after dispose" error.
2. Using the Wrong Mixin
SingleTickerProviderStateMixin supports one controller. If you need two or more, switch to TickerProviderStateMixin. Using the single version with multiple controllers causes a runtime error that is not immediately obvious.
3. Rebuilding the Entire Subtree
Wrapping your whole Scaffold in AnimatedBuilder means the entire screen rebuilds 60 times per second. Isolate the animated portion as tightly as possible.
4. Animating Layout Properties in Lists
Animating the height of items inside a ListView triggers layout for every visible item on every frame. Use SizeTransition or AnimatedList instead, which handle this efficiently.
5. Ignoring Reduced Motion Preferences
Some users have system-level reduced motion settings enabled. Respect them.
final reduceMotion = MediaQuery.of(context).disableAnimations;
final duration = reduceMotion
? Duration.zero
: const Duration(milliseconds: 400);6. Hardcoding Duration Everywhere
Define animation durations as constants in a central location. When you need to adjust the feel of your app, you change one value instead of hunting through dozens of files.
abstract class AppAnimations {
static const fast = Duration(milliseconds: 200);
static const medium = Duration(milliseconds: 400);
static const slow = Duration(milliseconds: 800);
static const pageTransition = Duration(milliseconds: 500);
}Personal Insights
In apps with heavy animations, I've learned that the biggest productivity gain comes from creating a small library of reusable animation wrappers. A FadeSlideIn widget that handles the controller lifecycle and accepts delay, duration, and offset parameters will save you from writing the same SingleTickerProviderStateMixin boilerplate dozens of times.
I've also learned the hard way that animation performance issues almost never show up on high-end development devices. Always test on the lowest-spec device your users might have. An animation that runs at 60fps on your Pixel 8 might stutter at 30fps on a budget phone, and RepaintBoundary placement becomes critical at that point.
One last thing: curves matter more than duration. A 400ms animation with Curves.easeOutCubic feels snappier than a 200ms animation with Curves.linear. Spend time experimenting with curves before reaching for shorter durations.
Conclusion
Flutter's animation system is remarkably flexible once you understand the layers. Start with implicit animations for quick wins, reach for explicit control when you need timing precision, and drop down to CustomPainter when the widget system cannot express your vision. Pair all of it with disciplined performance practices, and your apps will feel as responsive as they are beautiful.
If you need help designing a polished animation system for your Flutter app, get in touch.
Related Articles
What is Flutter? A Complete Beginner's Guide
Learn what Flutter is, how it works, and why modern product teams choose it. Discover Dart, widget architecture, and practical multi-platform development.
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Flutter Widget Lifecycle: From initState to dispose
Understand the StatefulWidget lifecycle step by step and avoid common lifecycle mistakes.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch