Learning about CustomPaint in Flutter

josuestuff

Josué Martínez

Posted on June 23, 2024

Learning about CustomPaint in Flutter

Something like a week ago I started to research about "how I can manually draw things in the Flutter canvas" and found information about CustomPaint and CustomPainter, which seems to be the main Flutter mechanism to allow devs draw specific things.

I'm not sure if there're more options or alternatives to achieve that, so for now let's just talk about my experience with Flutter's CustomPaint widget.

The Flutter art workshop, an analog perspective

Since the very first moment I started to learn Flutter, I inmediately noticed that it is, essentially, just an OpenGL canvas with a bunch of abstractions. I personally don't like that, not because of the abstractions themselves, but because using an OpenGL canvas doesn't feel... Native... Like, it's really easy to notice that.

And you may think: "Well, yeah, but if you think about that, every user interface is just an intangible abstraction, so... Who cares?". And yeah, you're right and I also agree with that, it's just a personal opinion and I've been learning how that approach for making user interfaces brings some benefits.

With that in mind, I ended up to the conclusion that Flutter it's likely an art workshop: it gives you canvas and a bunch of different brushes and tools to draw interfaces in a fast, simple and easy way. With this perspective, using an OpenGL canvas makes much more sense and helps bit to understand how CustomPaint works and it's more easy to explain it.

Alright, let's move on a bit more

So, you are working on a Flutter project and there's something specific you want to do, but Flutter doesn't have a widget for that and there's only one solution: create it by yourself. Well, the way to do that is by using CustomPaint, a widget that gives you direct access to the canvas, allowing you to draw things, but with one requirement: you need to give it a CustomPainter, which is basically Flutter telling you "Fine, you can draw here, but you must give an specialized artist for this very specific job!".

Creating a CustomPainter

At this point, you may be wondering what's a CustomPainter and how to create them, because if you try giving a literal CustomPainter to a CustomPaint, you'll get an error. That's because CustomPainter is an abstract class and we have to create a class that extends from CustomPainter instead. So, let's make it:

final class WavePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
  }

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

Now, let me explain this:

  • Our class is named WavePainter because we're going to make a simple audio waveform widget (as in the banner of this article)
  • All classes that extends from CustomPainter must override 2 methods:
    • paint: this one is where we're going to draw
    • shouldRepaint: and this one handles if paint should be called again

Pretty simple, right? The paint methods recieves a Canvas and a Size object for the drawing stuff and shouldRepaint recieves an old version of our CustomPainter to check for changes and, if so, return true to tell Flutter to repaint the widget, but we usually do this only if our widget has mutable data that is expected to change during the lifetime of our app.

Starting to draw

Alright, so our CustomPainter looks good so far, we can already give it to a CustomPaint, however, it will not draw anything because we haven't code what to draw yet, so let's start by creating a brush for our drawing:

final class WavePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // The Paint class is essentially a brush, so...
    final brush = Paint()
      // We set the color,
      ..color = Color(0xFFFAFAFA)
      // the thickness of the line
      ..strokeWidth = 3
      // and how we want them to end
      ..strokeCap = StrokeCap.round;
  }

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

At this point, you should check out the API reference for the Paint class to know what else you can do with it. Now, our brush is ready to use, so it's time to finish our drawing:

final class WavePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final brush = Paint()
      ..color = Colors.white
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round;

    var shift = 0.0;
    final verticalCenter = size.height / 2;
    final values = List<double>.generate(100, (_) {
      return Random().nextDouble() * verticalCenter;
    });

    for (var i = 0; i < values.length && shift < size.width; i++) {
      canvas.drawLine(
        Offset(shift, verticalCenter - values[i]),
        Offset(shift, verticalCenter + values[i]),
        brush
      );

      shift += 6;
    }
  }

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

If we put that inside of a CustomPaint, we should get something like this:

Figure 1: preview of the final result

Understanding what's going on

Now, again, I'm gonna explain these changes a bit more, but first, we need to understand how exactly positioning works in this case, which is pretty simple because we can understand it by just looking at this image:

Figure 2: how positioning works on the given canvas

As you can see, we start drawing at the top left corner of the given canvas, so if we don't want to draw outside of it, we should use positive numbers. That said, we will want to find the vertical center of that canvas and that's exactly what we did with size.height / 2, we're gonna use that number for both: positioning our bars and limit it's size.

By default, all custom paints gets a canvas of 300x300, but we can change that by putting our custom paint into another widget that could help to set a different size. In this case, I created a canvas of 400x100 by putting my custom paint inside of a Container, so our vertical center is 50 (unless we change the height of the Container).

That said, how we're gonna use that number as the limit of our bars? Well, that's pretty simple and we already did it in this line:

return Random().nextDouble() * verticalCenter;
Enter fullscreen mode Exit fullscreen mode

You see, since nextDouble will always return a number between 0.0 and 1.0, mutiplying that result by the vertical center will never exceed half the height of the canvas, so it's perfectly fine to do that. Now this section in the code we wrote makes more sense:

canvas.drawLine(
  Offset(shift, verticalCenter - values[i]),
  Offset(shift, verticalCenter + values[i]),
  brush
);
Enter fullscreen mode Exit fullscreen mode

drawLine requires an starting point and an end point for drawing lines, so we're doing this:

Figure 4: helper for vertical positioning

Pretty simple, right? Now, you might be thinking: "What about shift? What it does?". Well, shift is even more easy: it controls the horizontal position of our bars. Since we're not only putting them side by side, but also leaving a small space between them, we set shift to the width of the line (3) + space we want to have between bars (also 3) and we just keep incrementing it after every use.

Finally, to prevent our drawing to go out of the size of the given canvas, we put an additional condition to our for loop: shift < size.width. With that simple condition, no matter the width of the given canvas or how much data we have.

Final words

To be honest, learning how to do this was a really cool thing, I enjoyed the process and had a fun moment so far, despite I wasn't able to implement it in the project I'm working on.

Here is the full code for this article: https://gist.github.com/Miqueas/b66297d8de4a29000e9cb4d3f9cdc3f5

Anyways, I hope you enjoyed this too, feel free to share, have a nice day and see you another day 👋.

💖 💪 🙅 🚩
josuestuff
Josué Martínez

Posted on June 23, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related