Creating Smooth Circular Progress Animations in Flutter with Tween
Circular progress indicators are a common UI element used to visually represent the progress of a task, such as downloading a file, loading content, or completing a form. While Flutter provides a default CircularProgressIndicator, developers often need custom designs and more granular control over the animation. This article will guide you through creating a custom, animated circular progress indicator using Flutter's CustomPaint, AnimationController, and specifically, the powerful Tween class for smooth and controlled value interpolation.
Understanding the Core Components
To build our custom animated circular progress, we'll leverage several key Flutter features:
CustomPaint: This widget allows us to draw custom graphics directly onto the screen using aCustomPainter. We'll use it to draw the background circle and the progress arc.AnimationController: Manages the animation's lifecycle, including starting, stopping, and defining its duration. It produces values that change over time.Tween: Short for "in-betweening," aTweendefines a range of values (e.g., from 0.0 to 1.0) and how anAnimationController's raw animation value should be mapped to that range. This is crucial for smoothly interpolating between two values over the animation's duration. For our progress indicator, aTween<double>will map the animation's time to a progress percentage.AnimatedBuilder: This widget listens to anAnimationand rebuilds its child subtree whenever the animation's value changes. It's an efficient way to update the UI without rebuilding the entire widget tree.
Step-by-Step Implementation
Let's break down the creation of our custom circular progress animation.
1. Define the Custom Painter (CircularProgressPainter)
First, we need a CustomPainter to draw the static and dynamic parts of our progress indicator. This painter will take a progress value (a double between 0.0 and 1.0) and draw an arc corresponding to that progress.
import 'package:flutter/material.dart';
import 'dart:math' as math;
class CircularProgressPainter extends CustomPainter {
final double progress;
final Color backgroundColor;
final Color progressColor;
final double strokeWidth;
CircularProgressPainter({
required this.progress,
this.backgroundColor = Colors.grey,
this.progressColor = Colors.blue,
this.strokeWidth = 10.0,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width / 2, size.height / 2) - strokeWidth / 2;
// Background circle
final Paint backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(center, radius, backgroundPaint);
// Progress arc
final Paint progressPaint = Paint()
..color = progressColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round // Make the ends round
..strokeWidth = strokeWidth;
// Start angle (top) and sweep angle based on progress
final double startAngle = -math.pi / 2; // -90 degrees (top)
final double sweepAngle = 2 * math.pi * progress; // 360 degrees * progress
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false, // Do not connect to center
progressPaint,
);
}
@override
bool shouldRepaint(covariant CircularProgressPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.progressColor != progressColor ||
oldDelegate.strokeWidth != strokeWidth;
}
}
2. Create the Animated Widget (CircularProgressAnimation)
This widget will manage the AnimationController and Tween, and use an AnimatedBuilder to update the CustomPaint.
import 'package:flutter/material.dart';
// import 'circular_progress_painter.dart'; // Make sure to import your painter
class CircularProgressAnimation extends StatefulWidget {
final double initialProgress; // The progress to animate to
final Duration duration;
final Color backgroundColor;
final Color progressColor;
final double strokeWidth;
final double size; // Diameter of the circle
const CircularProgressAnimation({
Key? key,
this.initialProgress = 0.0,
this.duration = const Duration(seconds: 2),
this.backgroundColor = Colors.grey,
this.progressColor = Colors.blue,
this.strokeWidth = 10.0,
this.size = 100.0,
}) : assert(initialProgress >= 0.0 && initialProgress <= 1.0,
'initialProgress must be between 0.0 and 1.0'),
super(key: key);
@override
_CircularProgressAnimationState createState() => _CircularProgressAnimationState();
}
class _CircularProgressAnimationState extends State
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation; // This will hold the tweened value
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
);
// Define the Tween: maps the controller's 0.0-1.0 to our desired progress range
// In this case, we animate from 0.0 to widget.initialProgress
_animation = Tween<double>(begin: 0.0, end: widget.initialProgress).animate(_controller);
// Start the animation
_controller.forward();
}
@override
void didUpdateWidget(covariant CircularProgressAnimation oldWidget) {
super.didUpdateWidget(oldWidget);
// If the target progress changes, restart the animation
if (oldWidget.initialProgress != widget.initialProgress) {
_controller.duration = widget.duration; // Ensure duration is updated if it changes
_animation = Tween<double>(begin: oldWidget.initialProgress, end: widget.initialProgress).animate(_controller);
_controller.forward(from: 0.0); // Restart from the beginning
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _animation, // Listen to our tweened animation
builder: (context, child) {
return CustomPaint(
painter: CircularProgressPainter(
progress: _animation.value, // Use the current tweened value
backgroundColor: widget.backgroundColor,
progressColor: widget.progressColor,
strokeWidth: widget.strokeWidth,
),
);
},
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Explanation of Key Parts:
_controller = AnimationController(...): Initializes an animation controller that will run for the specifiedduration.vsync: thisprevents animations from consuming unnecessary resources when the off-screen._animation = Tween<double>(begin: 0.0, end: widget.initialProgress).animate(_controller);: This is whereTweencomes into play.Tween<double>(begin: 0.0, end: widget.initialProgress)creates aTweenthat will interpolate double values from0.0to the targetinitialProgress..animate(_controller)connects thisTweento ourAnimationController. TheTweentakes the_controller's raw value (which goes from 0.0 to 1.0 over its duration) and maps it to our defined range (0.0 towidget.initialProgress). This results in anAnimation<double>that produces values suitable for our progress display.
_controller.forward();: Starts the animation, making the controller's value increase from 0.0 to 1.0, which in turn drives our_animationfrom 0.0 towidget.initialProgress.didUpdateWidget: This method is crucial for making the animation responsive to changes in the target progress. WheninitialProgresschanges, we create a newTweenstarting from the old progress value and animating to the new one, then restart the controller.AnimatedBuilder(animation: _animation, builder: ...): This widget listens to changes in_animation. Whenever_animation.valuechanges, thebuilderfunction is called, rebuilding only theCustomPaintwidget with the new progress value. This is highly performant.
3. Example Usage
You can use this animated circular progress indicator in any Flutter widget, for instance, inside a Scaffold:
import 'package:flutter/material.dart';
// import 'circular_progress_animation.dart'; // Make sure to import your animation widget
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
double _currentProgress = 0.2; // Initial progress value
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Custom Circular Progress Animation'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressAnimation(
initialProgress: _currentProgress,
duration: const Duration(seconds: 2),
size: 150.0,
strokeWidth: 15.0,
progressColor: Colors.purple,
backgroundColor: Colors.purple.shade100,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: () {
setState(() {
// Update progress, simulating a task
_currentProgress = (_currentProgress + 0.2) % 1.0;
if (_currentProgress == 0.0) _currentProgress = 1.0; // Avoid animating to 0 from 0
});
},
child: const Text('Update Progress'),
),
],
),
),
),
);
}
}
Conclusion
By combining CustomPaint for drawing, AnimationController for timing, and critically, Tween for value interpolation, we've successfully created a highly customizable and smoothly animated circular progress indicator in Flutter. The Tween class allows us to precisely define the range of values our animation should produce, decoupling it from the raw 0.0-1.0 output of the controller and enabling diverse animation effects. This pattern is fundamental for building complex and engaging custom animations in your Flutter applications, offering full control over the visual presentation and animation behavior.