Animations Deep Dive: Implicit, Explicit, Hero & CustomPainter
From simple fades to complex physics-based motion — master every animation layer in Flutter
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Flutter's animation system spans three tiers: implicit animations (automatic transitions between property values), explicit animations (controller-driven with full timing control), and custom painting (Canvas-level pixel drawing). Hero animations handle shared-element transitions between routes. Physics-based animations use real-world simulation for natural motion.
Real-world relevance
In a production app, you'd use implicit animations for theme changes and button states, explicit animations for onboarding carousels and loading indicators, Hero transitions for image galleries (thumbnail → fullscreen), staggered animations for list item entrances, and CustomPainter for custom charts or progress rings. The choice depends on complexity — always start with the simplest tier that achieves the desired effect.
Key points
- The animation spectrum: implicit → explicit → custom — Flutter offers three animation tiers. Implicit: AnimatedContainer, AnimatedOpacity, AnimatedPositioned — you change a property and Flutter animates the transition automatically. Explicit: AnimationController + Tween + AnimatedBuilder — you control every aspect of timing, curves, and state. Custom: CustomPainter + Canvas — you draw frames manually for effects no widget can express. Interview rule of thumb: use the simplest tier that solves the problem. Using AnimationController for a color change is over-engineering; using AnimatedContainer for a staggered particle system is under-engineering.
- Implicit animations — AnimatedContainer, AnimatedOpacity, AnimatedSwitcher — AnimatedContainer: change any property (color, width, height, padding, decoration) and it animates the transition over a Duration with a Curve. No controller needed. AnimatedOpacity: animates opacity — great for fade in/out. AnimatedSwitcher: cross-fades between two child widgets with a transitionBuilder. Key requirement: the child's Key must change for AnimatedSwitcher to detect a swap. Common mistake: forgetting to set a key on the child — AnimatedSwitcher sees the same widget type and skips the animation.
- AnimationController — the explicit animation engine — AnimationController is a special Animation that generates values from 0.0 to 1.0 (by default) over a Duration. It requires a TickerProvider — use SingleTickerProviderStateMixin for one controller, TickerProviderStateMixin for multiple. Key methods: forward(), reverse(), repeat(), animateTo(), stop(), reset(). It produces values every frame (~60fps). Always dispose in dispose() to prevent memory leaks. The controller is the clock — it doesn't know about colors, sizes, or positions. That's the Tween's job.
- Tween and CurvedAnimation — mapping values and easing — Tween maps the controller's 0.0→1.0 to any range: `Tween(begin: 0, end: 300)`, `ColorTween(begin: Colors.red, end: Colors.blue)`, `Tween(begin: Offset(0, 1), end: Offset.zero)`. Chain with .animate(controller) or .animate(CurvedAnimation(parent: controller, curve: Curves.easeInOut)). CurvedAnimation wraps a controller with an easing curve. Common curves: easeIn, easeOut, easeInOut, bounceOut, elasticIn. Custom curves: extend Curve and override transformInternal(t).
- AnimatedBuilder vs AnimatedWidget — rebuilding efficiently — AnimatedBuilder takes an Animation, a builder function, and an optional child. The child parameter is critical for performance — it's the part of the subtree that DOESN'T change during animation. AnimatedBuilder rebuilds only the builder, not the child. AnimatedWidget is a base class that rebuilds when the animation ticks — cleaner for reusable animated widgets. Key difference: AnimatedBuilder is used inline in build(), AnimatedWidget is subclassed for reusable components. Both prevent rebuilding the entire widget tree on every frame.
- Hero transitions — shared element animations — Hero widget wraps a child with a tag. When navigating between routes where both have a Hero with the same tag, Flutter automatically animates the widget flying from source to destination. The flightShuttleBuilder callback customizes the in-flight widget. Common customizations: changing shape during flight (circle to rectangle), scaling, adding a drop shadow. Gotcha: Hero tags must be unique within a route. Gotcha: Hero doesn't work with dialogs or bottom sheets by default — use a custom PageRoute.
- Custom flightShuttleBuilder for advanced Hero transitions — The default Hero flight is a simple position+size animation. flightShuttleBuilder lets you control what the widget looks like mid-flight: `flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) { return AnimatedBuilder(animation: animation, builder: (_, __) => Material(elevation: animation.value * 8, borderRadius: BorderRadius.circular(animation.value * 20), child: fromContext.widget)); }`. This enables: morphing shapes, adding elevation shadows during flight, rotating, and applying color transitions.
- Staggered animations and Intervals — Staggered animations sequence multiple animations on a single controller using Interval. Each Interval specifies a start and end fraction (0.0 to 1.0) of the total duration. Example: opacity fades in during 0.0→0.3, then slides up during 0.2→0.7, then scales during 0.5→1.0 — overlapping creates a fluid staggered effect. Implementation: create separate Tweens with CurvedAnimation using Interval as the curve: `CurvedAnimation(parent: controller, curve: const Interval(0.0, 0.3, curve: Curves.easeOut))`.
- CustomPainter and Canvas — pixel-level control — CustomPainter gives you direct Canvas access. Override paint(Canvas canvas, Size size) to draw: canvas.drawCircle(), drawRect(), drawPath(), drawLine(), drawArc(). Use Paint for style (color, strokeWidth, style). The shouldRepaint() method controls when the painter redraws — return true when animation values change. Wrap in RepaintBoundary for isolation. Combine with AnimationController: pass the animation value to the painter, call shouldRepaint based on value changes. CustomPainter handles: charts, graphs, custom progress indicators, particle effects, game rendering.
- Physics-based animations — SpringSimulation and beyond — Flutter's physics library provides realistic motion. SpringSimulation: `controller.animateWith(SpringSimulation(SpringDescription(mass: 1, stiffness: 100, damping: 10), 0, 1, velocity))` — the widget bounces to rest like a real spring. GravitySimulation: objects fall with acceleration. FrictionSimulation: objects decelerate like sliding on a surface. ClampingScrollSimulation: the default scroll physics. Physics animations feel natural because they respond to initial velocity and have no fixed duration — they complete when the simulation reaches equilibrium.
- Performance: avoiding animation jank — Rules for smooth 60fps animations: (1) Use the child parameter in AnimatedBuilder — don't rebuild static subtrees. (2) Avoid triggering layout during animation — animate transform or opacity, not width/height when possible. (3) RepaintBoundary around animated widgets isolates repaint regions. (4) For CustomPainter, cache Paint objects and complex Paths — don't recreate every frame. (5) Use addPostFrameCallback for one-time setup, not in build(). (6) Profile with DevTools Performance overlay — green = good, red = jank.
- AnimatedList and SliverAnimatedList — list item animations — AnimatedList auto-animates item insertions and removals. Insert: `animatedListKey.currentState.insertItem(index)`. Remove: `animatedListKey.currentState.removeItem(index, (context, animation) => SizeTransition(sizeFactor: animation, child: removedWidget))`. The animation parameter in the itemBuilder is 0.0→1.0 for insertions, 1.0→0.0 for removals. SliverAnimatedList is the sliver version for CustomScrollView. These widgets make list changes feel alive — items slide in, fade out, and compress instead of appearing/disappearing abruptly.
Code example
// Complete animation examples — from implicit to physics-based
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'dart:math' as math;
// ===== 1. IMPLICIT ANIMATIONS =====
class ImplicitDemo extends StatefulWidget {
const ImplicitDemo({super.key});
@override
State<ImplicitDemo> createState() => _ImplicitDemoState();
}
class _ImplicitDemoState extends State<ImplicitDemo> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
width: _expanded ? 300 : 100,
height: _expanded ? 300 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.red,
borderRadius: BorderRadius.circular(_expanded ? 24 : 60),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _expanded
? const Icon(Icons.close, key: ValueKey('close'))
: const Icon(Icons.add, key: ValueKey('add')),
),
),
);
}
}
// ===== 2. EXPLICIT ANIMATION — Staggered =====
class StaggeredAnimation extends StatefulWidget {
const StaggeredAnimation({super.key});
@override
State<StaggeredAnimation> createState() => _StaggeredAnimationState();
}
class _StaggeredAnimationState extends State<StaggeredAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
late final Animation<Offset> _slide;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
// Staggered intervals — overlapping for fluid motion
_opacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
),
);
_slide = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.2, 0.7, curve: Curves.easeOut),
),
);
_scale = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.elasticOut),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose(); // CRITICAL — prevents memory leak
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
// Static child — NOT rebuilt on every frame
child: const Card(child: Padding(
padding: EdgeInsets.all(24),
child: Text('Staggered!', style: TextStyle(fontSize: 24)),
)),
builder: (context, child) {
return Opacity(
opacity: _opacity.value,
child: SlideTransition(
position: _slide,
child: ScaleTransition(
scale: _scale,
child: child, // Reuses the static child
),
),
);
},
);
}
}
// ===== 3. HERO TRANSITION with custom shuttle =====
class HeroSourceScreen extends StatelessWidget {
const HeroSourceScreen({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const HeroDestScreen()),
),
child: Hero(
tag: 'profile-hero',
flightShuttleBuilder: (_, animation, __, fromCtx, ___) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) => Material(
elevation: animation.value * 12,
borderRadius: BorderRadius.circular(
60 - (animation.value * 48), // Circle → rounded rect
),
clipBehavior: Clip.antiAlias,
child: Image.network(
'https://example.com/avatar.jpg',
width: 60 + (animation.value * 240),
height: 60 + (animation.value * 240),
fit: BoxFit.cover,
),
),
);
},
child: const CircleAvatar(radius: 30),
),
);
}
}
class HeroDestScreen extends StatelessWidget {
const HeroDestScreen({super.key});
@override
Widget build(BuildContext context) {
return Hero(
tag: 'profile-hero',
child: Image.network(
'https://example.com/avatar.jpg',
width: 300, height: 300, fit: BoxFit.cover,
),
);
}
}
// ===== 4. CUSTOM PAINTER — Animated Ring =====
class AnimatedRingPainter extends CustomPainter {
final double progress; // 0.0 → 1.0
final Color color;
AnimatedRingPainter({required this.progress, required this.color});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) / 2 - 10;
// Background ring
final bgPaint = Paint()
..color = color.withOpacity(0.2)
..strokeWidth = 8
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, bgPaint);
// Progress arc
final fgPaint = Paint()
..color = color
..strokeWidth = 8
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2, // Start from top
2 * math.pi * progress, // Sweep angle
false,
fgPaint,
);
// Center text
final textPainter = TextPainter(
text: TextSpan(
text: '${(progress * 100).toInt()}%',
style: TextStyle(color: color, fontSize: 24, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(
canvas,
center - Offset(textPainter.width / 2, textPainter.height / 2),
);
}
@override
bool shouldRepaint(AnimatedRingPainter old) =>
old.progress != progress || old.color != color;
}
// ===== 5. PHYSICS-BASED ANIMATION =====
class SpringDemo extends StatefulWidget {
const SpringDemo({super.key});
@override
State<SpringDemo> createState() => _SpringDemoState();
}
class _SpringDemoState extends State<SpringDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_runSpring();
}
void _runSpring() {
const spring = SpringDescription(
mass: 1.0,
stiffness: 180.0,
damping: 12.0, // Underdamped → bouncy
);
final simulation = SpringSimulation(spring, 0, 1, -2); // velocity = -2
_controller.animateWith(simulation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _controller.value * 200 - 100),
child: child,
);
},
child: GestureDetector(
onTap: () {
_controller.reset();
_runSpring();
},
child: Container(
width: 80, height: 80,
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
),
);
}
}Line-by-line walkthrough
- 1. ImplicitDemo: AnimatedContainer animates width, height, color, and borderRadius on tap — zero controllers, just setState
- 2. AnimatedSwitcher cross-fades icons — ValueKey tells Flutter these are different widgets that need a transition
- 3. StaggeredAnimation: single controller drives three Intervals — opacity (0-40%), slide (20-70%), scale (50-100%) overlap for fluid motion
- 4. Each Interval maps a portion of the controller's 0.0→1.0 to its own 0.0→1.0 — outside the interval, the value is clamped
- 5. AnimatedBuilder child parameter: the Card is built ONCE and reused every frame — only opacity/slide/scale wrapper rebuilds
- 6. _controller.dispose() in dispose() — prevents the ticker from firing after the widget is removed, avoiding the 'setState on disposed' error
- 7. HeroSourceScreen: flightShuttleBuilder customizes the mid-flight widget — morphing from circle (radius 60) to rectangle during navigation
- 8. AnimatedRingPainter: Canvas.drawArc with sweep angle based on progress — shouldRepaint compares progress values to avoid unnecessary redraws
- 9. TextPainter renders the percentage text centered on the canvas — layout() must be called before paint()
- 10. SpringSimulation: mass, stiffness, and damping control the bounce behavior — underdamped (low damping) creates visible oscillation
- 11. _controller.animateWith(simulation): the controller follows the physics simulation instead of a linear duration — no fixed end time
- 12. GestureDetector on the spring ball: reset + rerun creates a retrigger effect — interactive physics-based animation
Spot the bug
class FadeInWidget extends StatefulWidget {
const FadeInWidget({super.key});
@override
State<FadeInWidget> createState() => _FadeInWidgetState();
}
class _FadeInWidgetState extends State<FadeInWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_opacity = Tween<double>(begin: 0, end: 1).animate(_controller);
_controller.forward();
}
@override
Widget build(BuildContext context) {
return Opacity(
opacity: _opacity.value,
child: const Text('Hello, World!'),
);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter Animations Official Documentation (docs.flutter.dev)
- Implicit Animations in Flutter (docs.flutter.dev)
- Hero Animations — Flutter Cookbook (docs.flutter.dev)
- Flutter CustomPainter Guide (api.flutter.dev)
- Animation Deep Dive — Filip Hracek (Flutter) (medium.com)