DEV Community

Daniel Ko
Daniel Ko

Posted on

Creating a custom progress indicator

Disclaimer: I figured I add this just in case some people get confused. The images are cropped images and the actual widget is just the circle part and not the rectangle with rounded corners.

    Hello! Today, I will be covering something I recently encountered in a project that I thought I'd share on what I learned and hopefully you guys can learn from it too! I will be going over how to create a custom progress indicator. Bad title and bad intro aside, this is an example of what I am referring to:

image

For this blog, I am assuming you have basic knowledge of Flutter widgets and won't go into details of everything. Custom Paint will be the main focus here. Also, before I dive into this, feel free to checkout the project I'm working on here: https://github.com/Dan-Y-Ko/Flutter-Dart-Playground/tree/master/flutter/ui/banking_app_ui.

Core Widgets involved

The widgets that are used are the following:

  • Container
  • Stack
  • CustomPaint

The basics

    First we want to create the actual circle. To do this we can use simple container. But, we also want to overlap with our custom progress indicator so we will need to wrap it in a stack as well. The code should look like the following (it should be straightforward):

import 'package:flutter/material.dart';

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: Center(
          child: ProgressIndicatorButton(),
        ),
      ),
    );
  }
}

class ProgressIndicatorButton extends StatelessWidget {
  const ProgressIndicatorButton({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    const buttonSize = 80.0;
    const borderWidth = 2.0;

    return Stack(
      children: [
        Container(
          width: buttonSize,
          height: buttonSize,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(
              color: Colors.white,
              width: borderWidth,
            ),
          ),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The output should look like this:
image

Deep dive on Custom Paint

    This is where things get fun! I hope you remember your trigonometry and your unit circle (just kidding, Google is around). I just want to add a disclaimer that you can literally create anything with Custom Paint and going over every single possible scenario would end up being quite a long blog so I am only covering one aspect of it.

    The main thing a Custom Paint widget should take is a Custom Painter implementation.

How to create Custom Painter?

Custom Painter implementation will consist of the following as a base:

  • extending Custom Painter class
  • implement paint method
  • implement shouldRepaint method

Before going over paint method, let's go over what shouldRepaint is. It is essentially as it sounds. It returns a boolean value and if we want to create a new instance of this Custom Painter, we should return true. We do not need this however, so we will set it to false in this example.

Breaking down the paint method

The paint method takes in a size and uses the Canvas to do the actual drawing. To learn more about the different things you can create, have a look here: https://api.flutter.dev/flutter/dart-ui/Canvas-class.html. There are many things you can create: rectangle, circle, lines, and even custom paths. For this example we will be focusing on Arc.

void paint(Canvas canvas, Size size) {
    // 2
    final paint = Paint()
      // 3
      ..color = Colors.blue
      // 4
      ..strokeCap = StrokeCap.butt
      // 5
      ..style = PaintingStyle.stroke
      // 6
      ..strokeWidth = width;
    // 7
    final center = Offset(size.width / 2, size.height / 2);
    // 8
    final radius = (size.width / 2) - (width / 2);
    // 1
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false,
      paint,
    );
  }
Enter fullscreen mode Exit fullscreen mode
  1. As mentioned, we are creating an Arc so the drawArc method is used. The 1st argument is a Rect class, from which there are several options but we want a Circle and Rect.fromCircle will give us that effect. The other arguments will be discussed in a bit. The second argument specifies where we want the arc to start. The third argument specifies where we want the arc to end. The fourth argument specifies if we want to use the center to connect the arc to. This would create a line from the border to the center, which we do not want. The fifth argument is something created from the Paint class which will be discussed in a bit. Have a look here for reference: https://api.flutter.dev/flutter/dart-ui/Canvas/drawArc.html
  2. The Paint class is responsible for the visual effects of our custom progress indicator. This needs to get passed into the drawArc method.
  3. This gives the color.
  4. There are several options here, and what we choose here will decide what the "ends" of the arc will look like. For example, StrokeCap.round will cap our arc with a circle. I'm really not sure how else to explain so if you're still confused, have a look here: https://api.flutter.dev/flutter/dart-ui/StrokeCap-class.html
  5. We can use either fill or stroke here. We want to paint the color over a specific width only so we will use stroke in this example.
  6. This goes hand in hand with #5 and determines the thickness of the stroke. We want to specify the width to go along with PaintingStyle.stroke.
  7. Offset specifies specific points on x and y axis respectively. This center value gets passed into Rect.fromCircle.
  8. We get the radius by subtracting the width of the entire circle and the width of the "border". This radius value gets passed into Rect.fromCircle.

Before going into startAngle and sweepAngle, this is what your code should look like so far:

import 'package:flutter/material.dart';
// import 'dart:math' as math;

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: Center(
          child: ProgressIndicatorButton(),
        ),
      ),
    );
  }
}

class ProgressIndicatorButton extends StatelessWidget {
  const ProgressIndicatorButton({
    Key? key,
//     required this.startAngle,
//     required this.endAngle,
  }) : super(key: key);

//   final double startAngle;
//   final double endAngle;

  @override
  Widget build(BuildContext context) {
    const buttonSize = 80.0;
    const borderWidth = 2.0;

    return Stack(
      children: [
        Container(
          width: buttonSize,
          height: buttonSize,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(
              color: Colors.white,
              width: borderWidth,
            ),
          ),
        ),
        SizedBox(
          width: buttonSize,
          height: buttonSize,
          child: CustomPaint(
            painter: ProgressIndicatorPainter(
              width: borderWidth,
//               startAngle: startAngle,
//               sweepAngle: endAngle,
            ),
            child: Center(
              child: Container(
                width: buttonSize - 20.0,
                height: buttonSize - 20.0,
                decoration: const BoxDecoration(
                  color: Colors.blue,
                  shape: BoxShape.circle,
                ),
                child: const Center(
                  child: Icon(
                    Icons.done,
                    size: 30.0,
                  ),
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

class ProgressIndicatorPainter extends CustomPainter {
  const ProgressIndicatorPainter({
    required this.width,
//     required this.startAngle,
//     required this.sweepAngle,
  }) : super();

  final double width;
//   final double startAngle;
//   final double sweepAngle;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..strokeCap = StrokeCap.butt
      ..style = PaintingStyle.stroke
      ..strokeWidth = width;
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width / 2) - (width / 2);
//     canvas.drawArc(
//       Rect.fromCircle(center: center, radius: radius),
//       startAngle,
//       sweepAngle,
//       false,
//       paint,
//     );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Enter fullscreen mode Exit fullscreen mode

Make sure to comment out the code same place as I have or you will have errors.

The output should look like this:
image

What is startAngle and sweepAngle?

At a high level overview, startAngle and sweepAngle is what will determine where our arc will start and end (as was previously mentioned).

  • startAngle: By default, the starting position is at 0 radians in the unit circle. Another thing to note is that the direction in this arc is clockwise, instead of counter-clockwise. Instead of overcomplicating things, I just referenced the unit circle and added negative sign to everything. For example, if I wanted start position at the π/2 position on the unit circle, I'd set startAngle as -π/2.
  • sweepAngle: The way this works is whatever value is specified here will get added to the startAngle and that will be where the arcs ends. For example, referencing the unit circle, if we wanted an arc from π/2 to 0, we would need startAngle of -π/2 and sweepAngle of π/2. -π/2 + π/2 = 0. Yay, math class!

The final result

So back to the example referenced at the beginning:
image

How to go about it? Well, to change the progress indicators, we just need to tinker with the startAngle and sweepAngle only. Referencing the unit circle it looks to start at 2π/3 and end at 11π/6. Unfortunately, I couldn't really figure out an easy way to figure out the sweepAngle when using a custom startAngle like this. Simply adding doesn't really always work. Here's how I approached it. In quadrant I, we have the full quandrant so that's π/2. In quadrant II and IV, we have 2 π/6 slices. So that's π/6 + π/6 + π/2 which is 5π/6. This will be our sweepAngle. Full code below:

import 'package:flutter/material.dart';
import 'dart:math' as math;

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: Center(
          child: ProgressIndicatorButton(
            startAngle: -2 * math.pi / 3,
            endAngle: 5 * math.pi / 6,
          ),
        ),
      ),
    );
  }
}

class ProgressIndicatorButton extends StatelessWidget {
  const ProgressIndicatorButton({
    Key? key,
    required this.startAngle,
    required this.endAngle,
  }) : super(key: key);

  final double startAngle;
  final double endAngle;

  @override
  Widget build(BuildContext context) {
    const buttonSize = 80.0;
    const borderWidth = 2.0;

    return Stack(
      children: [
        Container(
          width: buttonSize,
          height: buttonSize,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(
              color: Colors.white,
              width: borderWidth,
            ),
          ),
        ),
        SizedBox(
          width: buttonSize,
          height: buttonSize,
          child: CustomPaint(
            painter: ProgressIndicatorPainter(
              width: borderWidth,
              startAngle: startAngle,
              sweepAngle: endAngle,
            ),
            child: Center(
              child: Container(
                width: buttonSize - 20.0,
                height: buttonSize - 20.0,
                decoration: const BoxDecoration(
                  color: Colors.blue,
                  shape: BoxShape.circle,
                ),
                child: const Center(
                  child: Icon(
                    Icons.done,
                    size: 30.0,
                  ),
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

class ProgressIndicatorPainter extends CustomPainter {
  const ProgressIndicatorPainter({
    required this.width,
    required this.startAngle,
    required this.sweepAngle,
  }) : super();

  final double width;
  final double startAngle;
  final double sweepAngle;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..strokeCap = StrokeCap.butt
      ..style = PaintingStyle.stroke
      ..strokeWidth = width;
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width / 2) - (width / 2);
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Enter fullscreen mode Exit fullscreen mode

Note

If you want to use degrees instead of radians, it's pretty simple. Just accept the value into the Custom Painer as degrees and then convert it to radians before adding it as argument to the drawArc method. All the other concepts are the same, just the actual values are different. Here's final code with degrees implemented:

import 'package:flutter/material.dart';
import 'dart:math' as math;

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: Center(
          child: ProgressIndicatorButton(
            startAngle: -120,
            endAngle: 150,
          ),
        ),
      ),
    );
  }
}

class ProgressIndicatorButton extends StatelessWidget {
  const ProgressIndicatorButton({
    Key? key,
    required this.startAngle,
    required this.endAngle,
  }) : super(key: key);

  final int startAngle;
  final int endAngle;

  @override
  Widget build(BuildContext context) {
    const buttonSize = 80.0;
    const borderWidth = 2.0;

    return Stack(
      children: [
        Container(
          width: buttonSize,
          height: buttonSize,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(
              color: Colors.white,
              width: borderWidth,
            ),
          ),
        ),
        SizedBox(
          width: buttonSize,
          height: buttonSize,
          child: CustomPaint(
            painter: ProgressIndicatorPainter(
              width: borderWidth,
              startAngle: startAngle,
              sweepAngle: endAngle,
            ),
            child: Center(
              child: Container(
                width: buttonSize - 20.0,
                height: buttonSize - 20.0,
                decoration: const BoxDecoration(
                  color: Colors.blue,
                  shape: BoxShape.circle,
                ),
                child: const Center(
                  child: Icon(
                    Icons.done,
                    size: 30.0,
                  ),
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

class ProgressIndicatorPainter extends CustomPainter {
  const ProgressIndicatorPainter({
    required this.width,
    required this.startAngle,
    required this.sweepAngle,
  }) : super();

  final double width;
  final int startAngle;
  final int sweepAngle;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..strokeCap = StrokeCap.butt
      ..style = PaintingStyle.stroke
      ..strokeWidth = width;
    final startAngleRad = startAngle * (math.pi / 180.0);
    final sweepAngleRad = sweepAngle * (math.pi / 180.0);
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width / 2) - (width / 2);
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngleRad,
      sweepAngleRad,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Enter fullscreen mode Exit fullscreen mode

Bonus! Two more examples

image

Same process. We have startAngle of 0 which we can also write as 0.0 in the code. As for sweepAngle, we have full quadrant in quadrant IV plus one π/6 slice in quadrant III. So that's π/6 + π/2 which is 4π/6 which will be our sweepAngle.

image

A little bit of a twist but still the same concepts. Here, we have
startAngle of -5π/4. For sweepAngle, we have full quadrant in quadrant II, then in quadrant III we have π/6 slice and π/12 slice. π/2 + π/6 + π/12 = 3π/4. This will be the sweepAngle.

Conclusion

The way I calculate the startAngle and sweepAngle is probably not the most ideal but it works. Anyways, I hope you learned something and if you have any questions feel free to reach out to me directly or leave a comment :)

Discussion (0)