Back to Posts

Flutter Animations and Custom Painters: Creating Beautiful UI Effects

16 min read

Learn how to create stunning animations and custom-drawn widgets in Flutter using the Animation framework and CustomPainter class. This guide covers everything from basic animations to complex custom paintings.

1. Basic Animations

Implicit Animations

class AnimatedContainerExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: Duration(milliseconds: 300),
      curve: Curves.easeInOut,
      width: isExpanded ? 200.0 : 100.0,
      height: isExpanded ? 200.0 : 100.0,
      decoration: BoxDecoration(
        color: isExpanded ? Colors.blue : Colors.red,
        borderRadius: BorderRadius.circular(isExpanded ? 20.0 : 10.0),
      ),
      child: Center(child: Text('Tap to animate')),
    );
  }
}

Explicit Animations

class RotatingLogo extends StatefulWidget {
  @override
  _RotatingLogoState createState() => _RotatingLogoState();
}

class _RotatingLogoState extends State<RotatingLogo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    
    _animation = Tween<double>(
      begin: 0,
      end: 2 * pi,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
    
    _controller.repeat();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Transform.rotate(
          angle: _animation.value,
          child: FlutterLogo(size: 100),
        );
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

2. Custom Painters

Basic Shape Drawing

class CirclePainter extends CustomPainter {
  final Color color;
  final double radius;

  CirclePainter({
    required this.color,
    required this.radius,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;

    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      radius,
      paint,
    );
  }

  @override
  bool shouldRepaint(CirclePainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.radius != radius;
  }
}

// Usage
CustomPaint(
  painter: CirclePainter(
    color: Colors.blue,
    radius: 50.0,
  ),
  size: Size(100, 100),
)

Complex Shapes and Paths

class WavePainter extends CustomPainter {
  final Color color;
  final double amplitude;
  final double frequency;
  final double phase;

  WavePainter({
    required this.color,
    this.amplitude = 20.0,
    this.frequency = 0.05,
    this.phase = 0.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.0;

    final path = Path();
    path.moveTo(0, size.height / 2);

    for (double x = 0; x < size.width; x++) {
      final y = size.height / 2 +
          amplitude * sin(frequency * x + phase);
      path.lineTo(x, y);
    }

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(WavePainter oldDelegate) {
    return oldDelegate.color != color ||
        oldDelegate.amplitude != amplitude ||
        oldDelegate.frequency != frequency ||
        oldDelegate.phase != phase;
  }
}

Animated Custom Painter

class AnimatedWaveWidget extends StatefulWidget {
  @override
  _AnimatedWaveWidgetState createState() => _AnimatedWaveWidgetState();
}

class _AnimatedWaveWidgetState extends State<AnimatedWaveWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _phaseAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );

    _phaseAnimation = Tween<double>(
      begin: 0,
      end: 2 * pi,
    ).animate(_controller);

    _controller.repeat();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _phaseAnimation,
      builder: (context, child) {
        return CustomPaint(
          painter: WavePainter(
            color: Colors.blue,
            phase: _phaseAnimation.value,
          ),
          size: Size(300, 200),
        );
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

3. Advanced Animation Techniques

Staggered Animations

class StaggeredAnimationExample extends StatefulWidget {
  @override
  _StaggeredAnimationExampleState createState() =>
      _StaggeredAnimationExampleState();
}

class _StaggeredAnimationExampleState extends State<StaggeredAnimationExample>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _fadeAnimation;
  late Animation<double> _slideAnimation;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );

    _fadeAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.0, 0.3, curve: Curves.easeIn),
    ));

    _slideAnimation = Tween<double>(
      begin: -100.0,
      end: 0.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.3, 0.6, curve: Curves.easeOut),
    ));

    _scaleAnimation = Tween<double>(
      begin: 0.5,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.6, 1.0, curve: Curves.elasticOut),
    ));
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Opacity(
          opacity: _fadeAnimation.value,
          child: Transform.translate(
            offset: Offset(_slideAnimation.value, 0.0),
            child: Transform.scale(
              scale: _scaleAnimation.value,
              child: Container(
                width: 200,
                height: 200,
                color: Colors.blue,
                child: Center(
                  child: Text('Staggered Animation'),
                ),
              ),
            ),
          ),
        );
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Hero Animations

// First screen
class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(builder: (context) => SecondScreen()),
        );
      },
      child: Hero(
        tag: 'imageHero',
        child: Image.asset(
          'assets/image.jpg',
          width: 100,
          height: 100,
        ),
      ),
    );
  }
}

// Second screen
class SecondScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Hero(
          tag: 'imageHero',
          child: Image.asset(
            'assets/image.jpg',
            width: 300,
            height: 300,
          ),
        ),
      ),
    );
  }
}

4. Custom Painter Effects

Gradient Progress Indicator

class GradientProgressPainter extends CustomPainter {
  final double progress;
  final List<Color> gradientColors;

  GradientProgressPainter({
    required this.progress,
    required this.gradientColors,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..strokeWidth = 10.0
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    // Background track
    paint.color = Colors.grey.withOpacity(0.2);
    canvas.drawArc(
      Offset.zero & size,
      -pi / 2,
      2 * pi,
      false,
      paint,
    );

    // Progress gradient
    paint.shader = SweepGradient(
      colors: gradientColors,
      startAngle: -pi / 2,
      endAngle: 3 * pi / 2,
    ).createShader(Offset.zero & size);

    canvas.drawArc(
      Offset.zero & size,
      -pi / 2,
      2 * pi * progress,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(GradientProgressPainter oldDelegate) {
    return oldDelegate.progress != progress;
  }
}

Particle System

class Particle {
  Offset position;
  Offset velocity;
  double size;
  Color color;
  double life;

  Particle({
    required this.position,
    required this.velocity,
    required this.size,
    required this.color,
    required this.life,
  });

  void update() {
    position += velocity;
    life -= 0.01;
  }
}

class ParticleSystemPainter extends CustomPainter {
  final List<Particle> particles;

  ParticleSystemPainter(this.particles);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..style = PaintingStyle.fill;

    for (final particle in particles) {
      paint.color = particle.color.withOpacity(particle.life);
      canvas.drawCircle(
        particle.position,
        particle.size * particle.life,
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(ParticleSystemPainter oldDelegate) => true;
}

class ParticleSystemWidget extends StatefulWidget {
  @override
  _ParticleSystemWidgetState createState() => _ParticleSystemWidgetState();
}

class _ParticleSystemWidgetState extends State<ParticleSystemWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  final List<Particle> _particles = [];
  final Random _random = Random();

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..addListener(_updateParticles);
    _controller.repeat();
  }

  void _updateParticles() {
    _particles.removeWhere((particle) => particle.life <= 0);

    if (_particles.length < 100) {
      _particles.add(
        Particle(
          position: Offset(
            _random.nextDouble() * 300,
            _random.nextDouble() * 300,
          ),
          velocity: Offset(
            _random.nextDouble() * 2 - 1,
            _random.nextDouble() * 2 - 1,
          ),
          size: _random.nextDouble() * 10 + 5,
          color: Colors.blue,
          life: 1.0,
        ),
      );
    }

    for (final particle in _particles) {
      particle.update();
    }

    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: ParticleSystemPainter(_particles),
      size: Size(300, 300),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Best Practices

  1. Performance Optimization
    • Use RepaintBoundary to isolate animations
    • Implement shouldRepaint correctly
    • Minimize canvas operations
    • Use hardware acceleration when possible
class OptimizedAnimation extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: CustomPaint(
        painter: MyOptimizedPainter(),
      ),
    );
  }
}
  1. Animation Composition
    • Break complex animations into smaller parts
    • Reuse animation controllers when possible
    • Use animation curves for natural motion
class ComposedAnimation extends StatelessWidget {
  final Animation<double> animation;

  ComposedAnimation({required this.animation});

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        SlideTransition(
          position: Tween<Offset>(
            begin: Offset(-1, 0),
            end: Offset.zero,
          ).animate(animation),
          child: FadeTransition(
            opacity: animation,
            child: ScaleTransition(
              scale: animation,
              child: YourWidget(),
            ),
          ),
        ),
      ],
    );
  }
}
  1. Custom Painter Organization
    • Separate drawing logic into methods
    • Use constants for magic numbers
    • Document complex drawing operations
class OrganizedPainter extends CustomPainter {
  static const double STROKE_WIDTH = 2.0;
  static const double CORNER_RADIUS = 10.0;

  @override
  void paint(Canvas canvas, Size size) {
    _drawBackground(canvas, size);
    _drawForeground(canvas, size);
    _drawDetails(canvas, size);
  }

  void _drawBackground(Canvas canvas, Size size) {
    // Background drawing logic
  }

  void _drawForeground(Canvas canvas, Size size) {
    // Foreground drawing logic
  }

  void _drawDetails(Canvas canvas, Size size) {
    // Details drawing logic
  }

  @override
  bool shouldRepaint(OrganizedPainter oldDelegate) => false;
}

Conclusion

Creating beautiful animations and custom-drawn widgets in Flutter requires:

  1. Understanding Animation Types

    • Implicit vs Explicit animations
    • Staggered animations
    • Hero transitions
  2. Custom Painter Mastery

    • Basic shapes and paths
    • Complex effects
    • Performance optimization
  3. Best Practices

    • Code organization
    • Performance considerations
    • Reusability

Remember to:

  • Keep animations smooth and natural
  • Optimize performance
  • Follow material design guidelines
  • Test on different devices
  • Handle edge cases

By following these practices, you can create engaging and performant animations that enhance your Flutter application's user experience.