Flutter animations: implicit vs explicit
Flutter has two animation systems: implicit and explicit. Choosing the wrong one leads to overly complex code for simple cases, or inflexible code for complex ones. Here is when to use each.
Implicit animations: widget-managed
Implicit animations transition between property values automatically when they change. Flutter handles the animation controller, tween, and rebuilding.
Use implicit animations for: simple value transitions where you just want "animate from old to new" without controlling playback.
// AnimatedContainer: animates any property change
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100,
decoration: BoxDecoration(
color: isExpanded ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(isExpanded ? 32 : 8),
),
)
// AnimatedOpacity: fade in/out
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isVisible ? 1.0 : 0.0,
child: const MyWidget(),
)
// AnimatedSwitcher: animate when the child changes
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: isLoading
? const CircularProgressIndicator(key: ValueKey('loading'))
: const OrdersList(key: ValueKey('orders')),
)
// TweenAnimationBuilder: animate any value with a Tween
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: progress),
duration: const Duration(milliseconds: 500),
builder: (_, value, child) {
return LinearProgressIndicator(value: value);
},
)
Built-in implicit widgets
AnimatedContainer— size, color, decoration, paddingAnimatedOpacity/AnimatedCrossFadeAnimatedAlign/AnimatedPositionedAnimatedPaddingAnimatedDefaultTextStyleAnimatedSwitcher— swap child widgets with animationTweenAnimationBuilder— any custom value
Explicit animations: controller-managed
Explicit animations give you a AnimationController you drive manually. Use them for:
- Looping animations (loading spinner, pulse)
- Animations triggered by events (not property changes)
- Chained or sequenced animations
- Physics-based animations
- Animations that need to be paused, reversed, or speed-changed
class PulseButton extends StatefulWidget {
const PulseButton({super.key, required this.onPressed, required this.child});
final VoidCallback onPressed;
final Widget child;
@override
State<PulseButton> createState() => _PulseButtonState();
}
class _PulseButtonState extends State<PulseButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _onTap() async {
await _controller.forward();
await _controller.reverse();
widget.onPressed();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.child,
),
);
}
}
Looping animations
// Start a continuous loop
_controller.repeat(reverse: true); // Ping-pong loop
_controller.repeat(); // Forward loop
// RotationTransition for a loading spinner
RotationTransition(
turns: _controller, // 0.0 to 1.0 = full rotation
child: const Icon(Icons.refresh),
)
Sequence with Interval
final slideIn = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.6, curve: Curves.easeOut), // Runs 0-60% of animation
));
final fadeIn = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.4, 1.0, curve: Curves.easeIn), // Runs 40-100%
),
);
// Start both from a single controller.forward()
Hero animations: shared element transitions
// In list:
Hero(
tag: 'product-${product.id}',
child: ProductImage(url: product.imageUrl),
)
// In detail screen:
Hero(
tag: 'product-${product.id}',
child: ProductImage(url: product.imageUrl, fullSize: true),
)
Flutter automatically animates the image flying between screens when navigating.
Decision guide
Animating a property when it changes value?
→ Implicit (AnimatedContainer, AnimatedOpacity, etc.)
Need to loop, sequence, or control playback?
→ Explicit (AnimationController)
Shared element between screens?
→ Hero widget
Physics-based (spring, bounce)?
→ Explicit with SpringSimulation or PhysicsBasedAnimation
Complex custom animation (drawn, shader)?
→ Explicit with CustomPainter
Common pitfalls
Not disposing AnimationController. Always dispose in dispose(). A leaked controller keeps the animation ticker running, burning battery and causing memory leaks.
Using AnimationController without vsync. Always pass vsync: this and mix in SingleTickerProviderStateMixin (for one animation) or TickerProviderStateMixin (for multiple). Without vsync, animations run at full speed even when off-screen.
Calling setState in animation listeners. Animation builders (AnimatedBuilder, AnimationBuilder) rebuild efficiently without setState. Calling setState inside an addListener rebuilds the entire widget tree on every animation frame.