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;
}
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
fromCustomPainter
must override 2 methods:-
paint
: this one is where we're going to draw -
shouldRepaint
: and this one handles ifpaint
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;
}
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;
}
If we put that inside of a CustomPaint
, we should get something like this:
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:
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;
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
);
drawLine
requires an starting point and an end point for drawing lines, so we're doing this:
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 👋.
Top comments (2)
If you're doing custom paint, study the protocols of Offset, Rect, Size, Align, etc, as they can save you a lot of math. If you find yourself ever typing "sin" or "cos", just know, there's a better way.
Oh! That's actually a good idea, thanks!