image

28 Apr 2026

9K

35K

Creating a Dotted Circle Loading Indicator with Rotation and Bounce in Flutter

Loading indicators are crucial for user experience, providing visual feedback during asynchronous operations. While Flutter offers default indicators, crafting custom animations can significantly enhance your app's aesthetic and brand identity. This article guides you through building a unique Flutter loading indicator: a dotted circle that elegantly rotates and bounces, captivating users while they wait.

Core Concepts

Our custom loading indicator will combine several Flutter animation fundamentals:

  • CustomPainter: To draw the series of dots forming our circle. This gives us pixel-level control over the indicator's appearance.
  • AnimationController and Tween: To manage the continuous rotation and the periodic bouncing motion.
  • AnimatedBuilder: To efficiently rebuild only the animated parts of our widget, ensuring smooth performance without rebuilding the entire widget tree.
  • Transform.rotate and Transform.translate: To apply the rotational and translational (bouncing) effects based on our animation values.

Step-by-Step Implementation

1. Setup the Stateful Widget

We'll start by creating a StatefulWidget, as we need to manage the lifecycle of our AnimationControllers.


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

class DottedLoadingIndicator extends StatefulWidget {
  final double dotRadius;
  final double circleRadius;
  final Color dotColor;
  final int numberOfDots;
  final Duration rotationDuration;
  final Duration bounceDuration;
  final double bounceHeight;

  const DottedLoadingIndicator({
    Key? key,
    this.dotRadius = 3.0,
    this.circleRadius = 30.0,
    this.dotColor = Colors.blue,
    this.numberOfDots = 12,
    this.rotationDuration = const Duration(seconds: 2),
    this.bounceDuration = const Duration(milliseconds: 600),
    this.bounceHeight = 10.0,
  }) : assert(numberOfDots > 0),
       super(key: key);

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

class _DottedLoadingIndicatorState extends State with TickerProviderStateMixin {
  late AnimationController _rotationController;
  late AnimationController _bounceController;
  late Animation _bounceAnimation;

  @override
  void initState() {
    super.initState();

    _rotationController = AnimationController(
      vsync: this,
      duration: widget.rotationDuration,
    )..repeat(); // Repeat indefinitely for continuous rotation

    _bounceController = AnimationController(
      vsync: this,
      duration: widget.bounceDuration,
    )..repeat(reverse: true); // Repeat and reverse for up-and-down bounce

    _bounceAnimation = Tween(begin: 0.0, end: widget.bounceHeight).animate(
      CurvedAnimation(
        parent: _bounceController,
        curve: Curves.easeInOutSine, // Smooth bounce curve
      ),
    );
  }

  @override
  void dispose() {
    _rotationController.dispose();
    _bounceController.dispose();
    super.dispose();
  }

  // ... build method will go here
}

2. Implement the CustomPainter for Dotted Circle

The _DottedCirclePainter is responsible for drawing our dots in a circular pattern. It calculates the position for each dot based on the circle's radius and the number of dots.


class _DottedCirclePainter extends CustomPainter {
  final double dotRadius;
  final double circleRadius;
  final Color dotColor;
  final int numberOfDots;

  _DottedCirclePainter({
    required this.dotRadius,
    required this.circleRadius,
    required this.dotColor,
    required this.numberOfDots,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()..color = dotColor;
    final Offset center = Offset(size.width / 2, size.height / 2);

    for (int i = 0; i < numberOfDots; i++) {
      final double angle = 2 * math.pi * (i / numberOfDots);
      final double x = center.dx + circleRadius * math.cos(angle);
      final double y = center.dy + circleRadius * math.sin(angle);
      canvas.drawCircle(Offset(x, y), dotRadius, paint);
    }
  }

  @override
  bool shouldRepaint(covariant _DottedCirclePainter oldDelegate) {
    return oldDelegate.dotRadius != dotRadius ||
           oldDelegate.circleRadius != circleRadius ||
           oldDelegate.dotColor != dotColor ||
           oldDelegate.numberOfDots != numberOfDots;
  }
}

3. Combine Animations with AnimatedBuilder

In the build method, we'll use an AnimatedBuilder to listen to our AnimationControllers. We'll wrap our CustomPaint widget with Transform.rotate for rotation and Transform.translate for the bouncing effect.


  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: Listenable.merge([_rotationController, _bounceController]),
      builder: (context, child) {
        final double rotationAngle = _rotationController.value * 2 * math.pi;
        final double bounceOffset = -_bounceAnimation.value; // Negative for upward bounce

        return Transform.translate(
          offset: Offset(0.0, bounceOffset),
          child: Transform.rotate(
            angle: rotationAngle,
            child: CustomPaint(
              size: Size(
                (widget.circleRadius + widget.dotRadius) * 2,
                (widget.circleRadius + widget.dotRadius) * 2,
              ),
              painter: _DottedCirclePainter(
                dotRadius: widget.dotRadius,
                circleRadius: widget.circleRadius,
                dotColor: widget.dotColor,
                numberOfDots: widget.numberOfDots,
              ),
            ),
          ),
        );
      },
    );
  }

Full Code Example

Here's the complete code for the DottedLoadingIndicator:


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

class DottedLoadingIndicator extends StatefulWidget {
  final double dotRadius;
  final double circleRadius;
  final Color dotColor;
  final int numberOfDots;
  final Duration rotationDuration;
  final Duration bounceDuration;
  final double bounceHeight;

  const DottedLoadingIndicator({
    Key? key,
    this.dotRadius = 3.0,
    this.circleRadius = 30.0,
    this.dotColor = Colors.blue,
    this.numberOfDots = 12,
    this.rotationDuration = const Duration(seconds: 2),
    this.bounceDuration = const Duration(milliseconds: 600),
    this.bounceHeight = 10.0,
  }) : assert(numberOfDots > 0),
       super(key: key);

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

class _DottedLoadingIndicatorState extends State with TickerProviderStateMixin {
  late AnimationController _rotationController;
  late AnimationController _bounceController;
  late Animation _bounceAnimation;

  @override
  void initState() {
    super.initState();

    _rotationController = AnimationController(
      vsync: this,
      duration: widget.rotationDuration,
    )..repeat();

    _bounceController = AnimationController(
      vsync: this,
      duration: widget.bounceDuration,
    )..repeat(reverse: true);

    _bounceAnimation = Tween(begin: 0.0, end: widget.bounceHeight).animate(
      CurvedAnimation(
        parent: _bounceController,
        curve: Curves.easeInOutSine,
      ),
    );
  }

  @override
  void dispose() {
    _rotationController.dispose();
    _bounceController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: Listenable.merge([_rotationController, _bounceController]),
      builder: (context, child) {
        final double rotationAngle = _rotationController.value * 2 * math.pi;
        final double bounceOffset = -_bounceAnimation.value;

        return Transform.translate(
          offset: Offset(0.0, bounceOffset),
          child: Transform.rotate(
            angle: rotationAngle,
            child: CustomPaint(
              size: Size(
                (widget.circleRadius + widget.dotRadius) * 2,
                (widget.circleRadius + widget.dotRadius) * 2,
              ),
              painter: _DottedCirclePainter(
                dotRadius: widget.dotRadius,
                circleRadius: widget.circleRadius,
                dotColor: widget.dotColor,
                numberOfDots: widget.numberOfDots,
              ),
            ),
          ),
        );
      },
    );
  }
}

class _DottedCirclePainter extends CustomPainter {
  final double dotRadius;
  final double circleRadius;
  final Color dotColor;
  final int numberOfDots;

  _DottedCirclePainter({
    required this.dotRadius,
    required this.circleRadius,
    required this.dotColor,
    required this.numberOfDots,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()..color = dotColor;
    final Offset center = Offset(size.width / 2, size.height / 2);

    for (int i = 0; i < numberOfDots; i++) {
      final double angle = 2 * math.pi * (i / numberOfDots);
      final double x = center.dx + circleRadius * math.cos(angle);
      final double y = center.dy + circleRadius * math.sin(angle);
      canvas.drawCircle(Offset(x, y), dotRadius, paint);
    }
  }

  @override
  bool shouldRepaint(covariant _DottedCirclePainter oldDelegate) {
    return oldDelegate.dotRadius != dotRadius ||
           oldDelegate.circleRadius != circleRadius ||
           oldDelegate.dotColor != dotColor ||
           oldDelegate.numberOfDots != numberOfDots;
  }
}

Explanation of Key Parts

  • _DottedCirclePainter: This custom painter draws numberOfDots small circles (dots) evenly distributed along the circumference of a larger circle defined by circleRadius. It's static, meaning its appearance doesn't change unless its properties are updated.
  • _rotationController: An AnimationController that runs indefinitely (.repeat()) from 0.0 to 1.0 over rotationDuration. Its value is multiplied by 2 * math.pi to get an angle in radians for a full 360-degree rotation.
  • _bounceController and _bounceAnimation: The _bounceController runs from 0.0 to 1.0 and then reverses (.repeat(reverse: true)). The _bounceAnimation uses a Tween to map this 0.0-1.0 range to a vertical offset (0 to bounceHeight) with a smooth Curves.easeInOutSine for a natural bounce feel.
  • AnimatedBuilder: This widget rebuilds its child whenever any of the animations it's listening to change value. We merge both _rotationController and _bounceController into a single Listenable.merge to efficiently update the UI.
  • Transform.translate and Transform.rotate: These widgets apply the calculated bounce and rotation transformations. The Transform.translate is applied first for the bouncing effect, and then Transform.rotate rotates the entire dotted circle. The order matters for how transformations are perceived.

Conclusion

By leveraging Flutter's powerful animation framework and CustomPainter, we've successfully created a dynamic and visually engaging loading indicator. This dotted circle with rotation and bounce not only signals activity but also adds a polished touch to your application's user interface. You can further customize this indicator by experimenting with different dot shapes, colors, animation curves, or by adding more complex chained animations to truly make it your own.

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