Animations
Implicit, explicit, and hero animation patterns for polished Flutter UIs
You are an expert in Flutter animations for building cross-platform apps with Flutter. ## Key Points - **Start with implicit animations.** If `AnimatedFoo` exists for your property, use it before reaching for `AnimationController`. - **Always dispose controllers** in `dispose()` to prevent memory leaks and frame callbacks after the widget is gone. - **Use `vsync: this`** with `TickerProviderStateMixin` to pause animations when the widget is not visible, saving battery. - **Prefer `Transform` over `AnimatedContainer` for layout-independent changes** (rotation, scale) — `Transform` does not trigger a relayout. - **Keep animations under 300-400ms** for UI transitions. Longer durations feel sluggish. - **Animating in `build` without a controller**: Calling `setState` in a loop to animate causes jank. Always use `AnimationController` or implicit widgets. - **Forgetting the `key` on `AnimatedSwitcher` children**: Without a changing key, `AnimatedSwitcher` does not know the child changed and will not animate. - **Running heavy animations on the main isolate**: Complex `CustomPainter` or shader work can drop frames. Profile with DevTools and consider `RepaintBoundary`. - **Using `TickerProviderStateMixin` (plural) when only one controller exists**: Use `SingleTickerProviderStateMixin` for a single controller — it is more efficient. - **Hardcoding durations for different platforms**: iOS users expect different animation timing than Android. Consider platform-aware duration constants.
skilldb get flutter-skills/AnimationsFull skill: 315 linesAnimations — Flutter
You are an expert in Flutter animations for building cross-platform apps with Flutter.
Core Philosophy
Flutter's animation system is designed around a progressive complexity model. For simple transitions -- a color change, a size change, a fade -- implicit animation widgets (AnimatedContainer, AnimatedOpacity, AnimatedSwitcher) handle everything automatically. You set the target value, and the widget animates to it. No controller, no ticker, no dispose. When you need precise control -- play, pause, reverse, synchronize multiple properties -- explicit animations with AnimationController give you full access to the animation lifecycle. The right approach is to start with implicit animations and reach for explicit ones only when the implicit API cannot express what you need.
Every animation runs on the main isolate's render pipeline, which means a dropped frame is visible to the user as jank. The target is 60fps (or 120fps on high-refresh displays), giving you roughly 16ms per frame to compute layouts, paint widgets, and run animation ticks. Heavy computation in a CustomPainter or an AnimatedBuilder that rebuilds a large widget subtree will drop frames. RepaintBoundary isolates expensive subtrees so they repaint independently, and profiling with Flutter DevTools' timeline view reveals exactly where frame time is being spent.
The vsync parameter on AnimationController is not boilerplate -- it is a critical optimization. When a widget using SingleTickerProviderStateMixin is off-screen (e.g., in a tab that is not visible), the ticker pauses automatically, stopping the animation and saving CPU/battery. Without vsync, the animation continues ticking in the background, consuming resources for frames that no one can see.
Anti-Patterns
-
Calling setState in a loop to animate values: Manually calling
setState60 times per second to increment a counter or change a position creates jank because each call triggers a full widget rebuild. UseAnimationControlleror implicit animation widgets, which are integrated with Flutter's rendering pipeline and handle frame timing correctly. -
Forgetting the key on AnimatedSwitcher children:
AnimatedSwitcherdetects that its child changed by comparing widget keys. If two children have the same type and no explicit key,AnimatedSwitchertreats them as the same widget and does not animate. Always provide aValueKeythat changes when the content changes. -
Using TickerProviderStateMixin when SingleTickerProviderStateMixin suffices:
TickerProviderStateMixin(plural) supports multiple tickers and is slightly less efficient. When a widget has exactly oneAnimationController, useSingleTickerProviderStateMixin. This is not just a style preference -- it avoids unnecessary overhead. -
Not disposing AnimationControllers: An undisposed
AnimationControllercontinues to schedule frame callbacks after its widget has been removed from the tree, causing "called on a disposed widget" errors or memory leaks. Always call_controller.dispose()in thedispose()method. -
Running expensive CustomPainter work without RepaintBoundary: A
CustomPainterthat paints a complex path or shader on every frame causes the entire parent subtree to repaint. Wrapping theCustomPaintin aRepaintBoundaryisolates its repainting from the rest of the widget tree, keeping other widgets from being unnecessarily repainted.
Overview
Flutter's animation system ranges from zero-code implicit animations to fully custom explicit animations. This skill covers implicit widgets for simple transitions, AnimationController for precise control, staggered animations, Hero transitions, and performance considerations.
Core Concepts
Implicit Animations
Implicit animation widgets automatically animate between old and new values. No controller needed.
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: isExpanded ? 300 : 100,
height: isExpanded ? 200 : 100,
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey.shade200,
borderRadius: BorderRadius.circular(isExpanded ? 16 : 8),
),
child: const Center(child: Text('Tap me')),
)
Common implicit widgets: AnimatedOpacity, AnimatedAlign, AnimatedPadding, AnimatedPositioned, AnimatedSwitcher, AnimatedCrossFade.
AnimatedSwitcher for Widget Transitions
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.1),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Text(
'$counter',
key: ValueKey<int>(counter), // key change triggers the animation
style: Theme.of(context).textTheme.displayMedium,
),
)
Explicit Animations with AnimationController
Use when you need to play, pause, reverse, or repeat animations.
class PulsingDot extends StatefulWidget {
const PulsingDot({super.key});
@override
State<PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
)..repeat(reverse: true);
_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 ScaleTransition(
scale: _scaleAnimation,
child: Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
);
}
}
Tween and CurvedAnimation
final colorTween = ColorTween(begin: Colors.red, end: Colors.blue);
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
final colorAnimation = colorTween.animate(curvedAnimation);
Implementation Patterns
Staggered Animations
class StaggeredListAnimation extends StatefulWidget {
const StaggeredListAnimation({super.key, required this.items});
final List<String> items;
@override
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 200 * widget.items.length),
vsync: this,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: widget.items.length,
itemBuilder: (context, index) {
final start = index / widget.items.length;
final end = (index + 1) / widget.items.length;
final animation = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOut),
),
);
return SlideTransition(
position: animation,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _controller,
curve: Interval(start, end),
),
child: ListTile(title: Text(widget.items[index])),
),
);
},
);
}
}
Hero Animations
// Source screen
GestureDetector(
onTap: () => context.push('/detail/${product.id}'),
child: Hero(
tag: 'product-${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(product.imageUrl, fit: BoxFit.cover),
),
),
)
// Destination screen
Hero(
tag: 'product-${product.id}',
child: Image.network(product.imageUrl, fit: BoxFit.cover),
)
Custom Animated Painter
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: 2),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: WavePainter(progress: _controller.value),
size: const Size(double.infinity, 100),
);
},
);
}
}
class WavePainter extends CustomPainter {
WavePainter({required this.progress});
final double progress;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue.withOpacity(0.5)
..style = PaintingStyle.fill;
final path = Path();
path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y = size.height * 0.5 +
sin((x / size.width * 2 * pi) + (progress * 2 * pi)) * 20;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) => oldDelegate.progress != progress;
}
Best Practices
- Start with implicit animations. If
AnimatedFooexists for your property, use it before reaching forAnimationController. - Always dispose controllers in
dispose()to prevent memory leaks and frame callbacks after the widget is gone. - Use
vsync: thiswithTickerProviderStateMixinto pause animations when the widget is not visible, saving battery. - Prefer
TransformoverAnimatedContainerfor layout-independent changes (rotation, scale) —Transformdoes not trigger a relayout. - Keep animations under 300-400ms for UI transitions. Longer durations feel sluggish.
Common Pitfalls
- Animating in
buildwithout a controller: CallingsetStatein a loop to animate causes jank. Always useAnimationControlleror implicit widgets. - Forgetting the
keyonAnimatedSwitcherchildren: Without a changing key,AnimatedSwitcherdoes not know the child changed and will not animate. - Running heavy animations on the main isolate: Complex
CustomPainteror shader work can drop frames. Profile with DevTools and considerRepaintBoundary. - Using
TickerProviderStateMixin(plural) when only one controller exists: UseSingleTickerProviderStateMixinfor a single controller — it is more efficient. - Hardcoding durations for different platforms: iOS users expect different animation timing than Android. Consider platform-aware duration constants.
Install this skill directly: skilldb add flutter-skills
Related Skills
Local Storage
Hive, SharedPreferences, and Drift patterns for local data persistence in Flutter
Navigation
GoRouter navigation patterns for declarative, deep-linkable Flutter routing
Networking
Dio HTTP client and API integration patterns for Flutter applications
Platform Channels
Platform channels for bridging Flutter with native Android (Kotlin) and iOS (Swift) code
State Management
Riverpod and Bloc state management patterns for scalable Flutter applications
Testing
Widget testing, unit testing, and integration testing patterns for Flutter apps