Creating an Animated Progress Indicator in Flutter — Part 1

Caleb Kiage
6 min readDec 26, 2018

--

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.

  1. foregroundColor
  2. value (which could be any decimal number between 0 and 1)
  3. 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.

Image showing the stroke extending beyond the constrained sides

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:

  1. updating the draw region of our canvas or
  2. 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:

Image showing the stroke within the confines of the constrained sides

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:

  1. 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.
  2. A double representing the start angle in radians.
  3. Another double called a sweep angle representing how many degrees around a circle from the starting point the arc ends.
  4. A boolean value that controls whether the arc is closed back to the center forming a circle sector.
  5. 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:

Circle progress bar

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.

--

--