Drawing and rotating arcs in Flutter

In this blog post, I will show you how to draw arcs. Furthermore, I will explain how to detect the drag and drop in the Widget to rotate the arcs. There are some packages in Flutter to help you draw arcs and donut graphs. However, if you want to interact with those charts, you will probably have to implement something yourself. Luckily, with the CustomPainter, this is not difficult at all.

Before you start reading, I will show you what I will build. Hopefully, this will show you whether this will be of any use or not, and you can keep looking.

Drawing the arcs

So let’s start by drawing the arcs. The first thing to do is create a Widget that extends the Custompainter. In this Widget, the code will execute some functions on the Canvas to draw beautiful stuff. When extending the CustomPainter, there are two methods that I need to override, the paint method and the shouldRepaint method. For now, let’s focus on the paint method. The method provides us with a canvas on which to draw stuff. For example, the following would draw a circle:

class CirclePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(Offset(size.width / 2, size.height / 2), 100, Paint());
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

As you can see, the canvas has a useful function for drawing a circle on the canvas. Luckily, the canvas also allows drawing an arc on the canvas. To draw an arc, I have to create a rectangle from the circle, the starting angle, the length of the arc, and a paint.

class ArcPainter extends CustomPainter {
  final double radius;

  ArcPainter(this.radius);

  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Rect.fromCircle(center: Offset(size.width / 2, size.height / 2), radius: radius);
    canvas.drawArc(rect, baseAngle, math.pi, false, Paint());
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

If this ArcPainter is wrapped in the CustomPaint Widget, these are no regular arcs. Why??

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

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200,
      height: 200,
      child: CustomPaint(
        painter: ArcPainter(100),
      ),
    );
  }
}
An arc that is a circle instead

The painter is not configured correctly. You can set the stroke style of the painter to PaintingStyle.stroke. If I change that and add some more arcs with a shorter sweep angle and a color, I get the following result:

Paint createPaintForColor(Color color) {
  return Paint()
    ..color = color
    ..strokeCap = StrokeCap.round
    ..style = PaintingStyle.stroke
    ..strokeWidth = 15;
}
The result of changing the stroke style of the painter. Complete code can be found here, which includes all the arcs.

Rotating the arcs

Now that there is an arc on the screen, I can start with the ‘difficult’ part. Listening to drags on the screen to rotate the arcs. While this step is not just calling an already existing method on the canvas, it does not have to be very complicated. The first step is to listen to the drag updates on the screen. After that, I will show you how to use the position updates to rotate the arc.

class ArcWidget extends StatefulWidget {
  const ArcWidget({Key? key}) : super(key: key);

  @override
  _ArcWidgetState createState() => _ArcWidgetState();
}

class _ArcWidgetState extends State<ArcWidget> {
  final double width = 200;
  final double height = 200;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: width,
      height: height,
      child: CustomPaint(
        painter: ArcPainter(100),
        child: GestureDetector(
          onVerticalDragStart: (value) {
            // setInitialState(value);
          },
          onHorizontalDragUpdate: (value) {
            // updateAngle(value);
          },
          onVerticalDragUpdate: (value) {
            // updateAngle(value);
          },
          onHorizontalDragStart: (value) {
            // setInitialState(value);
          },
        ),
      ),
    );
  }
}

Great, so I only have to add the GestureDetector as a child of the CustomPaint Widget. Now, I get all the updates of the dragging by the user. I can simply compute the angle based on the current position of the dragging and the end position. This is the angle that is rotated, so I need to provide that to the ArcPainter to update the rotation of the arcs.

class ArcWidget extends StatefulWidget {
  const ArcWidget({Key? key}) : super(key: key);

  @override
  _ArcWidgetState createState() => _ArcWidgetState();
}

class _ArcWidgetState extends State<ArcWidget> {
  final double width = 200;
  final double height = 200;
  double baseAngle = 0;
  Offset? lastPosition;
  double lastBaseAngle = 0;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: width,
      height: height,
      child: CustomPaint(
        painter: ArcPainter(100, baseAngle),
        child: GestureDetector(
          onVerticalDragStart: (value) {
            setInitialState(value);
          },
          onHorizontalDragUpdate: (value) {
            updateAngle(value);
          },
          onVerticalDragUpdate: (value) {
            updateAngle(value);
          },
          onHorizontalDragStart: (value) {
            setInitialState(value);
          },
        ),
      ),
    );
  }

  void updateAngle(DragUpdateDetails value) {
    double result = math.atan2(value.localPosition.dy - height/2, value.localPosition.dx - width/2) -
        math.atan2(lastPosition!.dy - height/2, lastPosition!.dx - width/2);
    setState(() {
      baseAngle = lastBaseAngle + result;
    });
  }

  void setInitialState(DragStartDetails value) {
    lastPosition = value.localPosition;
    lastBaseAngle = baseAngle;
  }
}

The only thing left is to adjust the ArcPainter and update it when the base angle changes. This can be done by returning true on the shouldRepaint method.

class ArcPainter extends CustomPainter {
  final double radius;
  double baseAngle;
  final Paint red = createPaintForColor(Colors.red);
  final Paint blue = createPaintForColor(Colors.blue);
  final Paint green = createPaintForColor(Colors.green);

  ArcPainter(this.radius, this.baseAngle);

  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Rect.fromCircle(center: Offset(size.width / 2, size.height / 2), radius: radius);
    canvas.drawArc(rect, baseAngle, sweepAngle(), false, blue);
    canvas.drawArc(rect, baseAngle + 2 / 3 * math.pi, sweepAngle(), false, red);
    canvas.drawArc(rect, baseAngle + 4 / 3 * math.pi, sweepAngle(), false, green);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;

  double sweepAngle() => 0.8 * 2 / 3 * math.pi;
}

Since I do not use any external packages in this example, I can share this Dartpad with the code. Thank you for reading! If you have any questions, suggestions, or requests, you can always leave a comment!

Leave a Reply