Flutter Circular gradient progress

Javier Torres
5 min readApr 25, 2020

--

Photo by Milad Fakurian on Unsplash

In the latest days, I was requested to create a custom circular progress, with an indefinite behavior, such as the one provided by the Flutter team. <CircularProgressIndicator> but with a particular change, the line had to be gradient. I am relatively new to Flutter, as my background is mostly Android and .NET and lately I am just focused on Native Android.

So I am going to explain how to get this with Flutter:

Let's begin!

I am going to create a new StatefulWidget which will be called CircularProgress:

So what are we doing here? nothing new:

  1. Creating a new StatefulWidget
  2. Assigning properties which we will use to customize our component
  3. Regular state for our StatefulWidget

What we need to do now is to create the shape that we want to paint, and the widget we are going to use is a CustomPaint.

 // 1
class CirclePaint extends CustomPainter{
final Color secondaryColor;
final Color primaryColor;
final double strokeWidth;

// 2
double _degreeToRad(double degree) => degree * math.pi / 180;

CirclePaint({this.secondaryColor = Colors.grey, this.primaryColor = Colors.blue, this.strokeWidth = 15});
@override
void paint(Canvas canvas, Size size) {
// 3
Paint paint = Paint()
..color = primaryColor
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;

double startAngle = _degreeToRad(0);
double sweepAngle = _degreeToRad(360);
// 4
canvas.drawArc(
Offset(0.0, 0.0) & Size(size.width, size.width), startAngle, sweepAngle,
false, paint..color = primaryColor);
}
@override
bool shouldRepaint(CirclePaint oldDelegate) {
return true;
}
}

So, what's going on here:

  1. We are creating a class CirclePaint which extends CustomPainter, with some attributes, secondaryColor, primaryColor, and strokeWidth
  2. A helper function which will turn degrees into radians
  3. The actual paint process, which will have a color, a type of Stroke (strokeCap) that has different types, but for this example, we will use roundCap. The style of paint used for this example will be stroke which will create an outline on the shape, and last the strokeWidth which is the size of the stroke
  4. The actual application of the painting process into the canvas. The paint process will begin at offSet 0.0 and it will vector to the size that is provided within the paint callback

So let's assign this to the return portion of our widget instead of the first Container, like this:

@override
Widget build(BuildContext context) {
return CustomPaint(
painter: CirclePaint(secondaryColor: widget.secondaryColor, primaryColor: widget.primaryColor, strokeWidth: widget.strokeWidth),
size: Size(widget.size, widget.size),
);
}

This is what we got at this point:

Still boring right? So what we are going to do is to give the circle that little gradient effect that makes our progress indicator a little bit more interesting, so let's go back to our paint method:

@override
void paint(Canvas canvas, Size size) {
// 1
double centerPoint = size.height / 2;

Paint paint = Paint()
..color = primaryColor
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;

// 2
paint.shader = SweepGradient(
colors: [secondaryColor, primaryColor],
tileMode: TileMode.repeated,
startAngle: _degreeToRad(270),
endAngle: _degreeToRad(270 + 360.0),
).createShader(Rect.fromCircle(center: Offset(centerPoint, centerPoint), radius: 0));

double startAngle = _degreeToRad(0);
double sweepAngle = _degreeToRad(360);

canvas.drawArc(
Offset(0.0, 0.0) & Size(size.width, size.width), startAngle, sweepAngle,
false, paint..color = primaryColor);
}
  1. We created a helper variable, centerPoint
  2. This is an important part, we will use a SweepGradient which helps our paint object to be able to add that gradient effect on the stroke

Let's run our code again!

This is starting to look like something we can use, but still, something happened right? that cap of our stroke lost the rounded edge! This is because SweepGradient takes away a fraction from the start of our stroke, and another at the end of it, so we need to make a compensation for it. Let's make some changes to our paint method:

@override
void paint(Canvas canvas, Size size) {

double centerPoint = size.height / 2;

Paint paint = Paint()
..color = primaryColor
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;

paint.shader = SweepGradient(
colors: [secondaryColor, primaryColor],
tileMode: TileMode.repeated,
startAngle: _degreeToRad(270),
endAngle: _degreeToRad(270 + 360.0),
).createShader(Rect.fromCircle(center: Offset(centerPoint, centerPoint), radius: 0));
// 1
var scapSize = strokeWidth * 0.70;
double scapToDegree = scapSize / centerPoint;
// 2
double startAngle = _degreeToRad(270) + scapToDegree;
double sweepAngle = _degreeToRad(360)-(2*scapToDegree);

canvas.drawArc(
Offset(0.0, 0.0) & Size(size.width, size.width), startAngle, sweepAngle,
false, paint..color = primaryColor);
}
  1. I added a scapSize which will be like the 70% of the stroke width to be able to consider wider strokes. Also, I added scapToDegree which will be this scapSize / centerPoint, this will provide compensation for the circumference of the arc.
  2. I start to draw at degree 270 and will add that little scapToDegree amount we created before, and also the sweepAngle will have to consider a decrease equals to the double of the scapToDegree

Note: You can play with the compensation values to match your needs or as you see fit. Try to modify them and have a better understanding of what’s going on there.

So let's run it again:

So far so good, our round cap is back! but that's not really great yet, we want to add rotation, so its time to add an animation to this shape, so let's go back to our State portion of the StatefulWidget:

// 1
class _CircularProgress extends State<CircularProgress> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation animation;

@override
void initState() {
super.initState();
// 2
controller = new AnimationController(vsync: this, duration: Duration(milliseconds: widget.lapDuration))..repeat();
animation = Tween<double>(begin: 0.0, end: 360.0).animate(_loaderController)
..addListener(() {
setState(() {});
});
// 3
@override
void dispose() {
controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
// 4
return RotationTransition(
turns: Tween(begin: 0.0, end: 1.0,).animate(controller),
child: CustomPaint(
painter: CirclePaint(progress: animation.value, secondaryColor: widget.secondaryColor, primaryColor: widget.primaryColor, strokeWidth: widget.strokeWidth),
size: Size(widget.size, widget.size),
),
);
}
  1. We add a MIXIN to our state SingleTickerProviderStateMixin
  2. We create an AnimationController with the vsync set to this (SingleTickerProviderStateMixin) the duration will be lapDuration we receive from the constructor on our stateful widget, this will be set on repeat() so we can see an infinite loop on it
  3. The dispose part of our stateFul widget should dispose the animation controller to prevent leaks and unwanted behaviors.
  4. We wrap our CustomPaint object with a RotationTransition and assign a new Tween which will indicate the turns, and that will animate our controller.

Let’s give this a shot!

That's great! we have our Circular animation! So we can use this in any place we require an indefinite progress indicator with this behavior!!

Thanks for reading! I will post more content as I can ;) Happy coding!

--

--

Javier Torres
Javier Torres

Written by Javier Torres

Software Developer with experience in Android, Flutter, .Net, SQL Server.