Back to Posts

How to Create a Circle Loading Animation in Flutter

9 min read

Loading animations are essential UI elements that provide visual feedback to users while content is being loaded. In this guide, we'll explore different ways to create circle loading animations in Flutter.

1. Using CustomPainter (Custom Implementation)

The most flexible way to create a custom circle loading animation is using AnimationController and CustomPainter.

import 'package:flutter/material.dart';

class CircleLoadingAnimation extends StatefulWidget {
  final Color color;
  final double size;
  final double strokeWidth;
  
  const CircleLoadingAnimation({
    Key? key,
    this.color = Colors.blue,
    this.size = 50.0,
    this.strokeWidth = 4.0,
  }) : super(key: key);

  @override
  _CircleLoadingAnimationState createState() => _CircleLoadingAnimationState();
}

class _CircleLoadingAnimationState extends State<CircleLoadingAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

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

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

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size(widget.size, widget.size),
      painter: CircleLoadingPainter(
        _controller,
        color: widget.color,
        strokeWidth: widget.strokeWidth,
      ),
    );
  }
}

class CircleLoadingPainter extends CustomPainter {
  final Animation<double> animation;
  final Color color;
  final double strokeWidth;

  CircleLoadingPainter(
    this.animation, {
    required this.color,
    required this.strokeWidth,
  }) : super(repaint: animation);

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

    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width - strokeWidth) / 2;

    // Draw background circle
    paint.color = color.withOpacity(0.2);
    canvas.drawCircle(center, radius, paint);

    // Draw animated arc
    paint.color = color;
    final sweepAngle = 2 * 3.14159 * animation.value;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -3.14159 / 2, // Start from top
      sweepAngle,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(covariant CircleLoadingPainter oldDelegate) =>
      animation != oldDelegate.animation ||
      color != oldDelegate.color ||
      strokeWidth != oldDelegate.strokeWidth;
}

Usage Example:

CircleLoadingAnimation(
  color: Colors.blue,
  size: 50.0,
  strokeWidth: 4.0,
)

2. Using CircularProgressIndicator (Built-in Widget)

Flutter provides a built-in CircularProgressIndicator widget for simple loading animations:

// Determinate progress indicator
CircularProgressIndicator(
  value: 0.7, // Progress from 0.0 to 1.0
  backgroundColor: Colors.grey[200],
  color: Colors.blue,
  strokeWidth: 4.0,
)

// Indeterminate progress indicator
CircularProgressIndicator(
  backgroundColor: Colors.grey[200],
  color: Colors.blue,
  strokeWidth: 4.0,
)

3. Advanced Circle Loading Animation

Here's an example of a more advanced circle loading animation with multiple circles:

class MultiCircleLoadingAnimation extends StatefulWidget {
  @override
  _MultiCircleLoadingAnimationState createState() =>
      _MultiCircleLoadingAnimationState();
}

class _MultiCircleLoadingAnimationState extends State<MultiCircleLoadingAnimation>
    with TickerProviderStateMixin {
  late List<AnimationController> controllers;
  
  @override
  void initState() {
    super.initState();
    controllers = List.generate(
      3,
      (index) => AnimationController(
        vsync: this,
        duration: Duration(seconds: 1),
      )..repeat(),
    );
    
    // Add delays for each controller
    for (int i = 0; i < controllers.length; i++) {
      Future.delayed(Duration(milliseconds: 300 * i), () {
        if (mounted) controllers[i].repeat();
      });
    }
  }

  @override
  void dispose() {
    for (var controller in controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: List.generate(
        3,
        (index) => ScaleTransition(
          scale: Tween(begin: 0.0, end: 1.0).animate(
            CurvedAnimation(
              parent: controllers[index],
              curve: Curves.easeInOut,
            ),
          ),
          child: Opacity(
            opacity: 1.0 - (index * 0.3),
            child: Container(
              width: 50.0 + (index * 10.0),
              height: 50.0 + (index * 10.0),
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                border: Border.all(
                  color: Colors.blue,
                  width: 2.0,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Best Practices

  1. Performance

    • Use RepaintBoundary when necessary to optimize rendering
    • Dispose of animation controllers properly
    • Use appropriate animation durations (typically 1-2 seconds)
  2. Customization

    • Make animations configurable (color, size, speed)
    • Consider different states (loading, error, success)
    • Support both light and dark themes
  3. Accessibility

    • Add semantic labels for screen readers
    • Consider reducing animation if user prefers reduced motion
    • Provide alternative feedback methods

Example Implementation with All Features

class AdvancedCircleLoading extends StatefulWidget {
  final Color color;
  final double size;
  final Duration duration;
  final String? semanticsLabel;
  final bool reduceAnimation;

  const AdvancedCircleLoading({
    Key? key,
    this.color = Colors.blue,
    this.size = 50.0,
    this.duration = const Duration(seconds: 1),
    this.semanticsLabel,
    this.reduceAnimation = false,
  }) : super(key: key);

  @override
  _AdvancedCircleLoadingState createState() => _AdvancedCircleLoadingState();
}

class _AdvancedCircleLoadingState extends State<AdvancedCircleLoading>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

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

    if (!widget.reduceAnimation) {
      _controller.repeat();
    } else {
      _controller.value = 0.5;
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: Semantics(
        label: widget.semanticsLabel ?? 'Loading indicator',
        child: AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return CustomPaint(
              size: Size(widget.size, widget.size),
              painter: AdvancedCirclePainter(
                animation: _controller,
                color: widget.color,
                reduceAnimation: widget.reduceAnimation,
              ),
            );
          },
        ),
      ),
    );
  }
}

Conclusion

Circle loading animations are versatile UI elements that can enhance user experience. Whether using the built-in CircularProgressIndicator or creating custom animations with CustomPainter, Flutter provides powerful tools for implementing beautiful loading indicators.

Remember to:

  • Choose the appropriate implementation based on your needs
  • Follow performance best practices
  • Consider accessibility
  • Maintain consistency with your app's design language

With these techniques, you can create engaging and efficient loading animations that enhance your Flutter application's user experience.