Flutter app development tutorials by Andrea Bizzotto

Flutter Custom Painting: Do Not Fear The Canvas

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:

Happy face on screen with CustomPainter

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:

Dartpad preview with two Containers

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:

Dartpad preview is missing the yellow Container

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:

Dartpad preview with fixed-size yellow Container

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:

Dartpad preview with resizable yellow Container

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 pass width: double.infinity, height: double.infinity as parameters to the yellow Container.


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 and size.height. This ensures that the mouth size is proportional to the parent widget.

This is the final result, updated in Dartpad:

Happy face on screen with CustomPainter

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 the paint() and shouldRepaint() 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 a Scaffold. In this case, everything works correctly if we pass a Container to the home argument of the MaterialApp.

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! πŸ™‚

Want more?

Support my work and fast-track your Flutter learning with my in-depth courses.