Widgets are awesome! And Flutter gives us plenty of them out of the box.
However, there are times where we want a bit more control about what we draw on screen.
And for that, we can draw directly into the canvas with a CustomPainter
.
So in this article, we will see how to (draw) paint a happy face on screen.
And since this tutorial doesn't require any 3rd party packages, we can build this with Dartpad:
Initial setup
We can start off with a MaterialApp
containing a Scaffold
, an outer white Container
, and an inner yellow Container
:
import 'package:flutter/material.dart';
final Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
home: Scaffold(
// Outer white container with padding
body: Container(
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 80),
color: Colors.white,
// Inner yellow container
child: Container(
color: Colors.yellow,
),
),
),
);
}
}
If we run this code on Dartpad, we get this:
Next, we can add a CustomPaint
widget as a child to the inner container:
Container(
color: Colors.yellow,
child: CustomPaint(painter: FaceOutlinePainter()),
),
CustomPaint
takes a painter
argument of type CustomPainter
, an abstract class from the Flutter APIs.
To draw things, we need to subclass CustomPainter
:
class FaceOutlinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// TODO: draw something with canvas
}
@override
bool shouldRepaint(FaceOutlinePainter oldDelegate) => false;
}
We will look at this in a second - but first - we have a problem to solve.
If we run the app again, something is not right, as our yellow Container
is no longer visible:
This happens because CustomPainter
doesn't always play nice with parent widgets that try to constrain its size.
To address this, we can specify the width and height of the parent Container
:
child: Container(
width: 300,
height: 300,
color: Colors.yellow,
child: CustomPaint(painter: FaceOutlinePainter()),
),
If we run the code again, we get:
That's better, but still not ideal. We want the containers to take up the entire space available in the UI window.
If only there was a widget that can tell us the size of the parent window, we could use that to adjust the width
and height
.
That widget exists and is called LayoutBuilder
. Let's use it:
LayoutBuilder(
// Inner yellow container
builder: (_, constraints) => Container(
width: constraints.widthConstraints().maxWidth,
height: constraints.heightConstraints().maxHeight,
color: Colors.yellow,
child: CustomPaint(painter: FaceOutlinePainter()),
),
),
Here we pass the maximum width and height of the LayoutBuilder
's constraints to the corresponding arguments in the Container
.
And if we run the code again, we can see that the containers now take up all the space available:
This even works as we resize the UI window in Dartpad.
UPDATE 28 Jan 2020: Someone pointed out that
LayoutBuilder
is not necessary in this case. Instead, it's enough to passwidth: double.infinity, height: double.infinity
as parameters to the yellowContainer
.
We're all good, and we can go back to our FaceOutlinePainter
:
class FaceOutlinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// TODO: draw something with canvas
}
@override
bool shouldRepaint(FaceOutlinePainter oldDelegate) => false;
}
Hello, I'm a painter 👨🎨
We can draw things with the Canvas
object in the paint()
method, and we can use shouldRepaint()
to specify when our painter should redraw. Since our painter doesn't have any mutable state, shouldRepaint()
can return false
in this example.
Canvas
is a big class with lots of methods for drawing various shapes.
These methods all have one argument in common: a Paint
object.
Want a paint object with an indigo stroke, 4pt thick? Here we go:
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4.0
..color = Colors.indigo;
How about a red fill? Easy peasy:
final paint = Paint()
..style = PaintingStyle.fill
..color = Colors.red;
And once we have a Paint
object, we can use it to draw things.
canvas.drawRect(
Rect.fromLTWH(20, 40, 100, 100),
paint,
);
Inside paint()
, we can call as many of the canvas drawing methods as we like.
Respecting boundaries
Boundaries are important in Flutter, just like in real life 😉
The paint()
method also gives us a Size
argument.
The bounds of our CustomPaint
are a function of the size: (0, 0, size.width, size.height)
.
Let's revisit our setup code:
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
debugShowCheckedModeBanner: false,
home: Scaffold(
// Outer white container with padding
body: Container(
color: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 80),
child: LayoutBuilder(
// Inner yellow container
builder: (_, constraints) => Container(
width: constraints.widthConstraints().maxWidth,
height: constraints.heightConstraints().maxHeight,
color: Colors.yellow,
child: CustomPaint(painter: FaceOutlinePainter()),
),
),
),
),
);
}
}
The size of the FaceOutlinePainter
matches the bounds of the yellow Container
, which in turn is affected by the padding of its parent Container
.
With this setup, the size
argument of the paint()
method will be equivalent to the screen size, minus the padding.
So if we want to stay within the bounds, our draw coordinates should be positive and not exceed size.width
and size.height
.
Let's put things together and draw a happy face:
class FaceOutlinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Define a paint object
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4.0
..color = Colors.indigo;
// Left eye
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(20, 40, 100, 100), Radius.circular(20)),
paint,
);
// Right eye
canvas.drawOval(
Rect.fromLTWH(size.width - 120, 40, 100, 100),
paint,
);
// Mouth
final mouth = Path();
mouth.moveTo(size.width * 0.8, size.height * 0.6);
mouth.arcToPoint(
Offset(size.width * 0.2, size.height * 0.6),
radius: Radius.circular(150),
);
mouth.arcToPoint(
Offset(size.width * 0.8, size.height * 0.6),
radius: Radius.circular(200),
clockwise: false,
);
canvas.drawPath(mouth, paint);
}
@override
bool shouldRepaint(FaceOutlinePainter oldDelegate) => false;
}
Most of the code above sets some coordinates, relative to the size of the canvas. And the drawing is done with some calls to the various canvas drawing methods.
Note: the mouth coordinates are a function of
size.width
andsize.height
. This ensures that the mouth size is proportional to the parent widget.
This is the final result, updated in Dartpad:
And here is the full example on Dartpad. You can play with it to see how the painter is updated as you resize the UI window.
Recap
Let's do a summary of what we have learned:
- we can use a
CustomPaint
widget to do custom painting. - this takes a painter object of type
CustomPainter
. - we can write our own
CustomPainter
subclass, and override thepaint()
andshouldRepaint()
methods. - we can use the
Canvas
object to draw different shapes. - we can use
Paint
objects with various fill and stroke properties, to configure the appearance of our shapes.
Finally, we must remember that our widget hierarchy needs some extra attention when we use a CustomPainter
. In other words, we need to specify the width
and height
of the parent Container
. If we want this to dynamically resize according to the window, we can use the constraints from LayoutBuilder
.
As a note, using a
LayoutBuilder
is not necessary if we don't use aScaffold
. In this case, everything works correctly if we pass aContainer
to thehome
argument of theMaterialApp
.
Go ahead my friend, and paint a work of art. 🎨
Conclusion
We have learned how to paint in Flutter, by drawing custom shapes inside a CustomPainter
subclass.
You can use this to define custom shapes, when the existing widget APIs are not sufficient.
You know what else is cool?
You can use Firebase ML Vision and the camera APIs to do realtime feature detection (barcodes, faces, labels and text). And use painters to draw overlays in realtime.
Buy hey, maybe this is a topic for a future tutorial. 😎
You can ping me on Twitter or send me an email if you'd like me to do a cool tutorial using ML Vision and the camera
Happy painting! 🙂