DEV Community

Shanu Kumawat
Shanu Kumawat

Posted on • Edited on

1 1 1 1

Creating Custom, Reusable Animations in Flutter That Don't Kill Performance

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:

  1. Rebuilding entire widget trees during animation
  2. Running too many simultaneous animations
  3. Using complex animations on low-end devices
  4. 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!
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now I can reuse this shake animation anywhere:

final Animation<double> _shakeAnimation = ShakeTween().animate(_controller);
Enter fullscreen mode Exit fullscreen mode

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
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

I can now use this anywhere in my app:

FadeScale(
  isActive: _isVisible,
  child: MyExpensiveWidget(),
)
Enter fullscreen mode Exit fullscreen mode

Performance Testing Tips

After implementing these patterns, I always test animations on real devices (especially low-end Android phones) using the following:

  1. Timeline View: Enable "Track widget builds" in Flutter DevTools to see if widgets are rebuilding unnecessarily.

  2. Performance Overlay: Add MaterialApp(showPerformanceOverlay: true) to check for dropped frames.

  3. 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,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

After implementing these techniques in multiple apps, I've seen dramatic performance improvements, especially on older devices. The key takeaways are:

  1. Prevent unnecessary rebuilds by using the child parameter in AnimatedBuilder
  2. Create custom Tweens for complex motion
  3. Use RepaintBoundary for complex widgets
  4. Always dispose your controllers
  5. 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!

Top comments (1)

Collapse
 
nh_thngchu_841311c38 profile image
dtelch

Goods

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay