Creating an Animated Progress Indicator in Flutter — Part 1
In this article, we’re going to explore the Flutter SDK’s CustomPaint
widget and leverage its functionality to create an animated progress widget. At the end of the series, we will have a progress indicator that behaves as shown below:
The first part of the series will tackle building a CircleProgressBar
and have it paint on screen with no animations. The second part will then update our CircleProgressBar
and add value change animations.
This article assumes the reader has a basic understanding of Google’s Flutter SDK. If Flutter is new to you, please check out their getting started guide here. Once you’ve gotten yourself familiarized with Flutter, you can come back and continue with the article.
To achieve this effect, we are going to build a CircleProgressBar
stateless widget that uses a CustomPaint
widget to paint our CircleProgressBarPainter
. Let’s briefly explore the CustomPaint
widget to find out what we can do with it.
CustomPaint
According to the flutter documentation, a CustomPaint
widget provides a canvas on which to draw during the paint phase.
In addition to a child
property, the custom paint widget also accepts a painter
and a foregroundPainter
.
For this tutorial, we’re going to use the foregroundPainter
property and provide our custom painter.
The code snippet below shows how we’ll be using the CustomPaint
widget within a custom CircleProgressBar
widget.
The CircleProgressBar
widget above is a stateless widget which means it doesn’t keep track of any state. In our case, the parent widget will keep track of the state and rebuild our widget when it changes. The CircleProgressBar
widget’s build tree consists of 3 widgets, an AspectRatio
widget, the CustomPaint
widget that we talked about earlier and a Container
that’s a child of our CustomPaint
. The CustomPaint
widget’s child can be any widget you’d like to show. To keep things simple, we’ll set an empty container as the child of our custom paint. The CircleProgressBar
has 3 parameters.
foregroundColor
value
(which could be any decimal number between 0 and 1)backgroundColor
(optional) which will be used to draw the progress bar background (if provided)
We use the AspectRatio
widget to ensure that our CustomPaint
keeps our desired aspect ratio. The value 1 we provided means that the aspect ratio is 1:1 i.e. a square.
The custom paint accepts an instance of CustomPainter
on either the painter
or the foregroundPainter
properties to draw on the canvas. The difference between foregroundPainter
and painter
is in the order in which they are layered. According to the Flutter docs:
When asked to paint, CustomPaint first asks its painter to paint on the current canvas, then it paints its child, and then, after painting its child, it asks its foregroundPainter to paint.
CustomPainter
A custom painter is what we’ll actually use to draw our shapes. To create a custom painter, we need to subclass the CustomPainter
class. CustomPainter
sub-classes need to implement the paint
and shouldRepaint
methods. Let’s take a look at the CircleProgressBarPainter
class which extends CustomPainter
.
Our class at the very least needs 2 parameters to draw the indicator. These are the percentage
which is a decimal number between 0 and 1, and the foregroundColor
. We use the percentage to calculate the progress bar geometry (an arc that’s whose angle is a percentage
of the complete circle). We’ll use the foregroundColor
to paint the arc’s stroke.
If a backgroundColor
is provided, then the painter draws a background circle on the canvas then draws a foreground arc. Otherwise, it just draws the foreground arc.
Drawing the background circle involves calling canvas object’s drawCircle
method. We pass to it the center coordinates which we got by taking the halfway point between the size bounds:
final Offset center = size.center(Offset.zero);
We also need to give the circle a radius. Since we want our painter to fit in any rectangle given to us, we first get the constrained size which corrects for the circle extending beyond the parent’s constraints (explained below). Next, we get the shortest side from the constrained size.
final Size constrainedSize = size - Offset(this.strokeWidth, this.strokeWidth);final shortestSide = Math.min(constrainedSize.width, constrainedSize.height);
The length of the shortestSide
will be our circle’s diameter. We need to get the radius of our circle from the diameter. Since diameter = 2 x radius
and we have our diameter already, we can calculate our radius by dividing diameter
by 2:
final radius = (shortestSide / 2);
You might be wondering why we had to get the constrainedSize
above and what it does for us. We did this because strokes usually extend on both sides of a line. What this means is that without compensating for the part of the stroke outside the circle, a part of the circle (or arc for our foreground) will appear to be drawn outside of the canvas’ bounds or clipped in a case where the painter’s ancestor clipped its children.
The image above shows how a circle would have been drawn if the radius was calculated without correcting for the stroke’s width that extends beyond the parent’s constraints. The black square shows the parent’s borders and we observe that the circle extends beyond the confines of the parent container. This may be corrected by either:
- updating the draw region of our canvas or
- correcting the radius of the circle.
We chose to go with the 1st option so we can keep things simple while confining the paint logic in our custom painter. We could have also gone with the 2nd option which involves having our radius corrected by getting the shortestSide / 2
then subtracting half the stroke width. A snippet of how to use the 2nd option is shown below.
final shortestSide = Math.min(size.width, size.height);final radius = (shortestSide / 2) - (this.strokeWidth / 2).ceil();
Both options above will give you a graphic as shown below which is what we want:
The last parameter we need is the background circle’s paint. This defines how the circle is drawn on the screen.
final backgroundPaint = Paint()
..color = this.backgroundColor
..strokeWidth = this.strokeWidth
..style = PaintingStyle.stroke;
Finally, our painter needs to draw an arc showing the completed percentage.
Arcs need a couple of parameters:
- A
Rect
as the first parameter to define the region to scale the arc to. We can get this rectangle given a circle’s radius and its center point. - A
double
representing the start angle in radians. - Another
double
called a sweep angle representing how many degrees around a circle from the starting point the arc ends. - A
boolean
value that controls whether the arc is closed back to the center forming a circle sector. - The
Paint
determining how the arc is drawn.
final foregroundPaint = Paint()
..color = this.foregroundColor
..strokeWidth = this.strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;// Start at the top. 0 radians represents the right edge
final double startAngle = -(2 * Math.pi * 0.25);
final double sweepAngle = (2 * Math.pi * (this.percentage ?? 0));canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
foregroundPaint,
);
Since we’d calculated the center and our radius for the background circle, all we need to do is get the foregroundColor
, the startAngle
and the sweepAngle
.
That’s all we need to draw our progress indicator.
We can now use the CircleProgressBar
widget in our application as shown below:
CircleProgressBar(
foregroundColor: Colors.red,
value: 0.5,
)
Which will draw something on the screen that looks like what we have below:
When we use the widget in a component that changes state, we can achieve the following output:
In the second part of the article, we’ll tackle animating the changes to our progress bar so we can get smooth transitions when the value changes.
The source code for this article can be found on the GitHub repository.