image

25 Mar 2026

9K

35K

Flutter Animated Loading Indicator with Dotted Circle and Bounce Animation

Loading indicators are crucial for enhancing user experience in mobile applications. They provide visual feedback, informing users that an operation is in progress and preventing frustration from unresponsive UIs. Flutter, with its powerful animation framework, makes it incredibly straightforward to create custom and engaging loading animations. In this article, we'll explore how to build a dynamic dotted circle loading indicator featuring a delightful bounce animation using Flutter's core animation capabilities.

Why Animated Loading Indicators?

A static loading spinner can feel generic and sometimes even slow. Animated loading indicators, especially custom ones, bring several benefits:

  • Improved User Experience: They make waiting more engaging and less tedious.
  • Brand Identity: Custom animations can align with your app's visual language and brand.
  • Perceived Performance: A well-designed animation can make loading times feel shorter than they actually are.
  • Clarity: They clearly communicate that the app is working, even if there's no immediate visual change in content.

Core Flutter Animation Concepts

To create our dotted circle loading indicator, we'll leverage several fundamental Flutter animation concepts:

  • AnimationController: Manages the animation's progress (start, stop, repeat, duration).
  • Animation<T>: Represents the current value of the animation at any given time. It's often driven by an AnimationController.
  • Tween<T>: Defines the range of an animation (e.g., from 0.0 to 1.0, or from red to blue). It interpolates values between the start and end.
  • Curves: Predefined animation curves (like Curves.bounceOut, Curves.elasticIn, Curves.easeInOut) that modify the rate of change of an animation over time, giving it a more natural or specific feel.
  • AnimatedBuilder: A powerful widget that rebuilds its child subtree whenever the provided Animation changes value. This is highly efficient as it only rebuilds the parts of the UI that depend on the animation.
  • CustomPainter: Allows us to draw custom graphics directly onto the canvas. This is essential for rendering our dynamic dots.

Implementing the Dotted Circle Loader

Our loading indicator will consist of a circle of dots, where one dot progressively "bounces" around the circle, creating a continuous animation. Here's a step-by-step breakdown of its implementation:

1. Project Setup

First, ensure you have a Flutter project. If not, create a new one:


flutter create dotted_loader_app
cd dotted_loader_app

2. The `DottedCircleLoader` Widget

We'll create a StatefulWidget to manage the animation controller and its lifecycle. This widget will be responsible for setting up and disposing of the animation.


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

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

  const DottedCircleLoader({
    Key? key,
    this.radius = 50.0,
    this.numberOfDots = 10,
    this.dotRadius = 4.0,
    this.dotColor = Colors.grey,
    this.activeDotColor = Colors.blue,
    this.duration = const Duration(seconds: 2),
  }) : assert(numberOfDots > 0), super(key: key);

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

class _DottedCircleLoaderState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation _animation;

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

    _animation = Tween(begin: 0.0, end: 1.0).animate(_controller);
  }

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

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

In this widget:

  • We use SingleTickerProviderStateMixin to provide a Ticker for our AnimationController.
  • _controller is initialized to animate from 0.0 to 1.0 over the specified duration and then repeat.
  • _animation is a simple Tween from 0.0 to 1.0, animated by _controller.
  • The build method uses an AnimatedBuilder to rebuild the CustomPaint whenever _animation changes value, passing the current animation value to our painter.

3. The `_DottedCirclePainter`

This is where the magic of drawing happens. The painter will calculate the positions of all dots and then determine which dot should currently be "bouncing" based on the animationValue.


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

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

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = Offset(size.width / 2, size.height / 2);
    final Paint dotPaint = Paint()..style = PaintingStyle.fill;

    // Calculate the index of the currently active dot
    // The animationValue goes from 0.0 to 1.0. Multiplying by numberOfDots
    // gives us a value that ranges from 0 to numberOfDots.
    // Floor it to get the current "active" dot index.
    final int activeDotIndex = (animationValue * numberOfDots).floor() % numberOfDots;

    // The bounce progress for the active dot, ranging from 0.0 to 1.0
    // This represents how far the active dot has progressed in its bounce cycle.
    final double bounceProgress = (animationValue * numberOfDots) % 1.0;

    for (int i = 0; i < numberOfDots; i++) {
      // Calculate the angle for each dot
      final double angle = (2 * pi / numberOfDots) * i;

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

      double currentDotRadius = dotRadius;
      Color currentDotColor = dotColor;

      // Check if this is the active dot
      if (i == activeDotIndex) {
        // Apply a bounce effect to the active dot radius
        // Using Curves.elasticOut for a springy bounce effect
        final double scaledProgress = Curves.elasticOut.transform(bounceProgress);
        currentDotRadius = dotRadius * (1 + 0.8 * scaledProgress); // Scale up to 1.8x original
        currentDotColor = activeDotColor;
      }

      // Draw the dot
      dotPaint.color = currentDotColor;
      canvas.drawCircle(Offset(x, y), currentDotRadius, dotPaint);
    }
  }

  @override
  bool shouldRepaint(covariant _DottedCirclePainter oldDelegate) {
    // Only repaint if the animation value changes
    return oldDelegate.animationValue != animationValue;
  }
}

In the _DottedCirclePainter:

  • paint method calculates the center of the canvas.
  • It determines activeDotIndex by multiplying animationValue by numberOfDots and taking the floor. The modulo operator (%) ensures it wraps around correctly.
  • bounceProgress is derived from animationValue to track the individual bounce cycle of the active dot.
  • It iterates through each dot, calculating its position using trigonometric functions (cos and sin).
  • If the current dot is the activeDotIndex, it applies a scale transformation using Curves.elasticOut.transform(bounceProgress) to create the bounce effect. The radius and color are updated accordingly.
  • Finally, canvas.drawCircle renders each dot.
  • shouldRepaint is overridden to optimize performance, ensuring the painter only repaints when the animationValue actually changes.

4. Integrating into Your App

You can now use this loader anywhere in your app. For instance, in your main.dart:


import 'package:flutter/material.dart';
import 'package:dotted_loader_app/dotted_circle_loader.dart'; // Assuming your loader is in this file

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dotted Loader Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Dotted Circle Loader'),
        ),
        body: const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                'Loading data...',
                style: TextStyle(fontSize: 18),
              ),
              SizedBox(height: 30),
              DottedCircleLoader(
                radius: 60.0,
                numberOfDots: 12,
                dotRadius: 5.0,
                dotColor: Colors.purpleAccent,
                activeDotColor: Colors.deepPurple,
                duration: Duration(milliseconds: 1500),
              ),
              SizedBox(height: 30),
              DottedCircleLoader(
                radius: 40.0,
                numberOfDots: 8,
                dotRadius: 3.5,
                dotColor: Colors.cyan,
                activeDotColor: Colors.teal,
                duration: Duration(milliseconds: 1000),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

This example demonstrates how to use the DottedCircleLoader with different configurations to showcase its flexibility.

Conclusion

Flutter's animation framework, combined with CustomPainter, provides an incredibly powerful and flexible toolkit for creating highly customized and engaging UI elements. By understanding concepts like AnimationController, AnimatedBuilder, and custom painting, you can go beyond standard widgets and build truly unique visual experiences for your users. The dotted circle loader with a bounce animation is just one example of how these tools can be combined to create a subtle yet effective loading indicator, making your app feel more alive and responsive.

Feel free to experiment with different Curves, dot counts, colors, and animation durations to tailor this loader to your specific application's design language.

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