This content originally appeared on DEV Community and was authored by Shanu Kumawat
If you've ever tried to add animations to your Flutter app, you know there's a fine line between delightful user experience and watching your app's performance graph take a nosedive. After spending countless hours debugging janky animations and memory leaks, I've compiled my hard-earned lessons into this guide.
The Animation Problem
Flutter offers incredible animation capabilities out of the box, but there's a catch: poorly implemented animations can destroy your app's performance. The biggest culprits I've encountered are:
- Rebuilding entire widget trees during animation
- Running too many simultaneous animations
- Using complex animations on low-end devices
- Failing to dispose of animation controllers
These issues become especially problematic when you're trying to create reusable animation components. Let's fix that.
Starting With the Basics: Animation Controllers
Every good Flutter animation starts with proper controller management. Here's my go-to pattern for creating a reusable, performance-friendly animated widget:
class OptimizedAnimatedWidget extends StatefulWidget {
final Widget child;
final Duration duration;
const OptimizedAnimatedWidget({
Key? key,
required this.child,
this.duration = const Duration(milliseconds: 300),
}) : super(key: key);
@override
_OptimizedAnimatedWidgetState createState() => _OptimizedAnimatedWidgetState();
}
class _OptimizedAnimatedWidgetState extends State<OptimizedAnimatedWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose(); // This is crucial!
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child,
);
},
child: widget.child, // This is our performance trick!
);
}
}
The secret sauce here is passing the child
to AnimatedBuilder
. This prevents Flutter from rebuilding the child on every animation frame, which is a common performance killer.
Technique #1: RepaintBoundary for Complex Animations
When your animations involve complex widgets, wrap them in a RepaintBoundary
:
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: RepaintBoundary(child: child),
);
},
child: widget.child,
);
}
This creates a new "layer" for Flutter's rendering engine, preventing the entire widget tree from repainting on each animation frame.
Technique #2: Custom Tween Classes for Reusability
To make animations truly reusable, I create custom Tween classes:
class ShakeTween extends Tween<double> {
ShakeTween({double begin = 0.0, double end = 10.0})
: super(begin: begin, end: end);
@override
double lerp(double t) {
// Custom shake animation
if (t < 0.25) {
return -sin(t * 4 * pi) * end! * (t * 4);
} else if (t < 0.75) {
return sin((t - 0.25) * 4 * pi) * end! * (0.75 - t) * 1.33;
} else {
return -sin((t - 0.75) * 4 * pi) * end! * (1 - t) * 4;
}
}
}
Now I can reuse this shake animation anywhere:
final Animation<double> _shakeAnimation = ShakeTween().animate(_controller);
Technique #3: Composable Animation Widgets
For truly reusable animations, I build composable widgets that can be stacked:
class FadeScale extends StatelessWidget {
final Widget child;
final Duration duration;
final bool isActive;
const FadeScale({
Key? key,
required this.child,
this.duration = const Duration(milliseconds: 200),
this.isActive = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: isActive ? 1.0 : 0.0),
duration: duration,
curve: Curves.easeOut,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.scale(
scale: 0.8 + (value * 0.2),
child: child,
),
);
},
child: child, // This prevents unnecessary rebuilds
);
}
}
I can now use this anywhere in my app:
FadeScale(
isActive: _isVisible,
child: MyExpensiveWidget(),
)
Performance Testing Tips
After implementing these patterns, I always test animations on real devices (especially low-end Android phones) using the following:
Timeline View: Enable "Track widget builds" in Flutter DevTools to see if widgets are rebuilding unnecessarily.
Performance Overlay: Add
MaterialApp(showPerformanceOverlay: true)
to check for dropped frames.Memory Profiling: Watch memory usage during animations to catch leaks from undisposed controllers.
Real-World Example: A Reusable "Heart Beat" Animation
Here's a complete example of a performance-optimized, reusable heart beat animation I use in production:
class HeartBeat extends StatefulWidget {
final Widget child;
final Duration duration;
final bool isAnimating;
const HeartBeat({
Key? key,
required this.child,
this.duration = const Duration(milliseconds: 1500),
this.isAnimating = true,
}) : super(key: key);
@override
_HeartBeatState createState() => _HeartBeatState();
}
class _HeartBeatState extends State<HeartBeat> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
);
_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.2), weight: 10),
TweenSequenceItem(tween: Tween<double>(begin: 1.2, end: 1.0), weight: 10),
TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.15), weight: 10),
TweenSequenceItem(tween: Tween<double>(begin: 1.15, end: 1.0), weight: 10),
TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.0), weight: 60),
]).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
if (widget.isAnimating) {
_controller.repeat();
}
}
@override
void didUpdateWidget(HeartBeat oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isAnimating != oldWidget.isAnimating) {
if (widget.isAnimating) {
_controller.repeat();
} else {
_controller.stop();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child,
);
},
child: widget.child,
);
}
}
Conclusion
After implementing these techniques in multiple apps, I've seen dramatic performance improvements, especially on older devices. The key takeaways are:
- Prevent unnecessary rebuilds by using the
child
parameter inAnimatedBuilder
- Create custom Tweens for complex motion
- Use
RepaintBoundary
for complex widgets - Always dispose your controllers
- Test on real, low-end devices
What animation challenges have you faced in your Flutter projects? I'd love to hear about them in the comments below!
This content originally appeared on DEV Community and was authored by Shanu Kumawat

Shanu Kumawat | Sciencx (2025-03-04T12:06:04+00:00) Creating Custom, Reusable Animations in Flutter That Don’t Kill Performance. Retrieved from https://www.scien.cx/2025/03/04/creating-custom-reusable-animations-in-flutter-that-dont-kill-performance/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.