image

23 Feb 2026

9K

35K

Creating a Dotted Circle Loading Indicator Animation in Flutter

Loading indicators are crucial for enhancing user experience in mobile applications. They provide visual feedback, assuring users that an operation is in progress and preventing frustration from unresponsive UIs. While Flutter offers default loading indicators like CircularProgressIndicator, custom animations can significantly improve an app's aesthetic appeal and brand identity. This article will guide you through building a custom, animated dotted circle loading indicator using Flutter's powerful animation framework and CustomPainter.

Understanding the Core Components

To create our dotted circle animation, we'll primarily leverage three core Flutter features:

  • AnimationController: Manages the animation's state, including starting, stopping, and reversing. It produces values between 0.0 and 1.0 over a specified duration.
  • Tween: Defines a range of values (e.g., colors, sizes, angles) and interpolates between them over the animation's duration. We'll use it implicitly via AnimationController's value range.
  • CustomPainter: Provides a canvas to draw custom shapes and paths. This is where we will draw our dots along a circular path.

Step-by-Step Implementation

Let's break down the creation of our animated dotted circle loading indicator.

1. Setting up the Animation Controller

First, we need a StatefulWidget to manage our animation. We'll initialize an AnimationController and dispose of it properly.


import 'package:flutter/material.dart';
import 'dart:math' as math;

class DottedCircleLoader extends StatefulWidget {
  final double radius;
  final int numberOfDots;
  final double dotRadius;
  final Color dotColor;
  final Duration animationDuration;

  const DottedCircleLoader({
    Key? key,
    this.radius = 50.0,
    this.numberOfDots = 12,
    this.dotRadius = 3.0,
    this.dotColor = Colors.blue,
    this.animationDuration = const Duration(seconds: 1),
  }) : super(key: key);

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

class _DottedCircleLoaderState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: widget.animationDuration,
    )..repeat(); // Makes the animation repeat indefinitely
  }

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

  @override
  Widget build(BuildContext context) {
    // We'll build this part later
    return Container();
  }
}
2. Creating the DottedCirclePainter

This is where the magic happens. We'll create a custom painter that draws dots around a circle, with one dot highlighted based on the animation's current value.


class DottedCirclePainter extends CustomPainter {
  final double animationValue;
  final double radius;
  final int numberOfDots;
  final double dotRadius;
  final Color dotColor;

  DottedCirclePainter({
    required this.animationValue,
    required this.radius,
    required this.numberOfDots,
    required this.dotRadius,
    required this.dotColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final paint = Paint(); // Color will be set per dot

    for (int i = 0; i < numberOfDots; i++) {
      final double angle = 2 * math.pi * (i / numberOfDots);

      // Calculate the position for each dot on the circle
      final double x = center.dx + radius * math.cos(angle);
      final double y = center.dy + radius * math.sin(angle);

      // Determine which dot should be highlighted and adjust its properties
      final int currentDotIndex = (animationValue * numberOfDots).floor();

      // Calculate the 'distance' from the leading dot, considering wrap-around
      int distance = (i - currentDotIndex + numberOfDots) % numberOfDots;

      double opacity = 0.2; // Base opacity
      double currentDotRadius = dotRadius;

      if (distance == 0) { // The 'current' leading dot
        opacity = 1.0;
        currentDotRadius = dotRadius * 1.5;
      } else if (distance == 1) { // The dot immediately behind
        opacity = 0.7;
        currentDotRadius = dotRadius * 1.2;
      } else if (distance == 2) { // The dot two steps behind
        opacity = 0.4;
        currentDotRadius = dotRadius * 1.0;
      } else if (distance == 3) { // The dot three steps behind
        opacity = 0.25;
        currentDotRadius = dotRadius * 0.8;
      }

      canvas.drawCircle(
        Offset(x, y),
        currentDotRadius,
        paint.copyWith(color: dotColor.withOpacity(opacity)),
      );
    }
  }

  @override
  bool shouldRepaint(covariant DottedCirclePainter oldDelegate) {
    return oldDelegate.animationValue != animationValue;
  }
}
3. Integrating into the Widget Tree with AnimatedBuilder

Now, back in our _DottedCircleLoaderState, we'll use an AnimatedBuilder to rebuild our CustomPaint widget whenever the _controller's value changes.


// ... inside _DottedCircleLoaderState class ...

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return CustomPaint(
            size: Size(widget.radius * 2, widget.radius * 2),
            painter: DottedCirclePainter(
              animationValue: _controller.value,
              radius: widget.radius,
              numberOfDots: widget.numberOfDots,
              dotRadius: widget.dotRadius,
              dotColor: widget.dotColor,
            ),
          );
        },
      ),
    );
  }
}

Complete Example

Here's the full code for the DottedCircleLoader widget, ready to be dropped into your Flutter application:


import 'package:flutter/material.dart';
import 'dart:math' as math;

class DottedCircleLoader extends StatefulWidget {
  final double radius;
  final int numberOfDots;
  final double dotRadius;
  final Color dotColor;
  final Duration animationDuration;

  const DottedCircleLoader({
    Key? key,
    this.radius = 50.0,
    this.numberOfDots = 12,
    this.dotRadius = 3.0,
    this.dotColor = Colors.blue,
    this.animationDuration = const Duration(seconds: 1),
  }) : super(key: key);

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

class _DottedCircleLoaderState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

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

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return CustomPaint(
            size: Size(widget.radius * 2, widget.radius * 2),
            painter: DottedCirclePainter(
              animationValue: _controller.value,
              radius: widget.radius,
              numberOfDots: widget.numberOfDots,
              dotRadius: widget.dotRadius,
              dotColor: widget.dotColor,
            ),
          );
        },
      ),
    );
  }
}

class DottedCirclePainter extends CustomPainter {
  final double animationValue;
  final double radius;
  final int numberOfDots;
  final double dotRadius;
  final Color dotColor;

  DottedCirclePainter({
    required this.animationValue,
    required this.radius,
    required this.numberOfDots,
    required this.dotRadius,
    required this.dotColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final paint = Paint(); // Color will be set per dot

    for (int i = 0; i < numberOfDots; i++) {
      final double angle = 2 * math.pi * (i / numberOfDots);

      final double x = center.dx + radius * math.cos(angle);
      final double y = center.dy + radius * math.sin(angle);

      // Determine which dot should be highlighted and adjust its properties
      final int currentDotIndex = (animationValue * numberOfDots).floor();

      // Calculate the 'distance' from the leading dot, considering wrap-around
      int distance = (i - currentDotIndex + numberOfDots) % numberOfDots;

      double opacity = 0.2; // Base opacity
      double currentDotRadius = dotRadius;

      if (distance == 0) { // The 'current' leading dot
        opacity = 1.0;
        currentDotRadius = dotRadius * 1.5;
      } else if (distance == 1) { // The dot immediately behind
        opacity = 0.7;
        currentDotRadius = dotRadius * 1.2;
      } else if (distance == 2) { // The dot two steps behind
        opacity = 0.4;
        currentDotRadius = dotRadius * 1.0;
      } else if (distance == 3) { // The dot three steps behind
        opacity = 0.25;
        currentDotRadius = dotRadius * 0.8;
      }


      canvas.drawCircle(
        Offset(x, y),
        currentDotRadius,
        paint.copyWith(color: dotColor.withOpacity(opacity)),
      );
    }
  }

  @override
  bool shouldRepaint(covariant DottedCirclePainter oldDelegate) {
    return oldDelegate.animationValue != animationValue;
  }
}

Explanation of Key Code Sections

  • SingleTickerProviderStateMixin: This mixin provides the vsync object required by AnimationController to ensure animations only consume resources when visible on screen.
  • _controller.repeat(): This method makes the animation loop indefinitely, perfect for a loading indicator.
  • paint method logic:
    • It iterates numberOfDots times to draw each dot.
    • angle = 2 * math.pi * (i / numberOfDots) calculates the angular position for each dot around the circle (a full circle is 2 * pi radians).
    • Trigonometry (math.cos, math.sin) is used to convert the angle and radius into (x, y) coordinates relative to the circle's center.
    • currentDotIndex = (animationValue * numberOfDots).floor() determines which dot is currently "active" or leading based on the animation's progress (0.0 to 1.0).
    • The distance calculation with modulo operator handles the wrap-around effect, allowing dots to fade in/out smoothly as they "follow" the leading dot.
    • Opacity and radius are adjusted based on this distance to create a visual trailing effect.
  • AnimatedBuilder: This widget listens to the _controller and rebuilds its builder function whenever the animation value changes. This ensures our CustomPaint is redrawn with the updated animationValue.
  • shouldRepaint: This method in CustomPainter is crucial for performance. It tells Flutter whether the painter needs to redraw. We return true if animationValue has changed, ensuring repaints only occur when necessary.

Customization and Enhancements

The provided code offers several parameters for customization:

  • radius: Controls the radius of the circle on which the dots are placed.
  • numberOfDots: Adjusts the density of the dots.
  • dotRadius: Changes the base size of the individual dots.
  • dotColor: Sets the color of the dots.
  • animationDuration: Controls how fast the animation loops.

You can further enhance this by:

  • Adding more sophisticated easing curves to the animation.
  • Varying the dot size or color more dynamically based on its position.
  • Implementing a gradient for the dots.

Conclusion

Building custom loading indicators in Flutter using CustomPainter and the animation framework provides immense flexibility and allows developers to create unique and engaging user experiences. This dotted circle loader example demonstrates how to combine these powerful tools to produce a smooth, visually appealing animation that can significantly elevate the professional feel of your Flutter applications. Experiment with the parameters and logic to create your own distinctive loading indicators!

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is