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 anAnimationController.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 (likeCurves.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 providedAnimationchanges 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
SingleTickerProviderStateMixinto provide a Ticker for ourAnimationController. _controlleris initialized to animate from 0.0 to 1.0 over the specified duration and then repeat._animationis a simpleTweenfrom 0.0 to 1.0, animated by_controller.- The
buildmethod uses anAnimatedBuilderto rebuild theCustomPaintwhenever_animationchanges 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:
paintmethod calculates the center of the canvas.- It determines
activeDotIndexby multiplyinganimationValuebynumberOfDotsand taking the floor. The modulo operator (%) ensures it wraps around correctly. bounceProgressis derived fromanimationValueto track the individual bounce cycle of the active dot.- It iterates through each dot, calculating its position using trigonometric functions (
cosandsin). - If the current dot is the
activeDotIndex, it applies a scale transformation usingCurves.elasticOut.transform(bounceProgress)to create the bounce effect. The radius and color are updated accordingly. - Finally,
canvas.drawCirclerenders each dot. shouldRepaintis overridden to optimize performance, ensuring the painter only repaints when theanimationValueactually 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.