Flutter drawing, erasing, and undo with CustomPainter

Ivan Štajcer
6 min readApr 14, 2021

Introduction

Have you ever tried to implement drawing in your application? I was interested in doing so but couldn’t find any solution on the internet that would satisfy me. Hence, this blog has been made.

In this blog, we will learn how to implement drawing in an application so that it doesn’t leave these weird-looking traces when you try to draw and also that it doesn’t get janky in the process.

This is my first ever blog, so hopefully, you will enjoy it and learn something new from it :D.

Code for this application can be found on my GitHub repository: https://github.com/igniti0n/draw_application

CustomPaint

In Flutter, you draw on the screen using the CustomPaint widget in combination with CustomPainter class that provides basic functionalities to draw something on the screen. You can create your own painter by extending CustomPainter class, and therefore implementing the two required methods: paint() and shouldRepaint(). Method paint() is called when an object needs to paint, and all paint-related operations are inside of this function, while souldRepaint() determines should it paint again. In the case of drawing application, shouldRepaint() always returns true.

CustomPainter

Problem

To draw on the screen, we need to know where and when the user makes contact with the screen. Luckily, Flutter gives us GestureDetector widget that has onPanStart, onPanUpdate, and onPanEnd to keep track of the user starting, moving, and releasing contact from the screen. So, we can keep track of those points (in a List for ex.), and draw lines in between them so that on the screen the user is presented with a fully drawn line, right?

Maybe you encountered this problem already and you came here looking for answers, resulting drawing would work for a very small stroke width, otherwise it looks a mess.

Result of the above code

One solution to this problem is to draw circles along the path of the drawn line so that these holes go away.

This does solve the problem, but doing it this way is not efficient and if you draw a bit more on the screen it becomes a bad UX.

Solution

As a solution for this, while doing a lot of experimenting and fails, I settled on storing individual user interactions in a data model that I called CanvasPath. It represents data contained in one user interaction to the screen, so from the moment user touched the screen and after he releases finger from the screen. Data stored in it is a list of points (offsets), the Paint to use when painting on the screen, and a Path consisting of the points of interaction to the screen.

You may ask, what is the difference between this path and the list of offsets? The difference is that you can draw a path by just calling canvas.drawPath(), while the drawLine() needs to be called in between all the offsets. Also, the path can be moved to make a line between any two offsets by calling path.lineTo() or just move to a different offset without making a trace with path.moveTo().

So, the whole drawing consists of a list of these CanvasPath objects, so a Drawing class was made to serve as a way to represent a drawing and it only holds a list of CanvasPath objects.

Here is how the application flow goes:

Application flow

Bloc is a state management solution in Flutter, if you are not familiar with it, just treat it as a way of providing what data needs to be drawn. That is all that it does, it has a Drawing object that has a list of CanvasPath-s and bloc provides it to the CustomPainter so that he can draw all the paths to the screen.

As you can see in the code, bloc just updates the list and yields that data. The list is then received by the CustomPainter. Undo is implemented by just removing the last element in the list, it is easy to implement it using this setup.

And the gesture handlers add a new CanvasPath to the list and update it accordingly.

gesture handlers

This is the workflow of the application, now I need to tell you how the path in the CanvasPath is updated with movePathOrMakeLineWithPath() and movePathTo() functions. Also, how the paint() method paint method is implemented. Paint() method goes through all the paths in every single CanvasPath and draws those paths on the screen, and also draws a circle for every offset stored.

This is better than calling canvas.drawLine() in every offset along with canvas.DrawCircle(). We just draw a path for the current CanvasPath with canvas.drawPath(). After experimenting on how to tweak this, I came up with this made-up formula for the radius of the drawn circle, so feel free to make changes to this and see how it affects the drawing.

The way movePathOrMakeLineWithPath() and movePathTo() methods work, is that movePathTo() functions just moves the path to the given offset, so it just calls path.moveTo(). The movePathOrMakeLineWithPath() method of CanvasPath calculates the distance between the current offset and the new offset and decides to either make a curve between the current offset and the new one or to just move to the new offset based on their distance between each other.

From experimenting, I noticed that with small stroke width, drawing circles along with the path is not good. Therefore, for small stroke widths curves are always made. The reason why the path is moved on small distances (if the user is moving the finger slowly) is that it causes the path to make weird patterns in some cases.

weird patterns

So, with all this implemented the drawing should work smoothly in your application!

I am sure you can make better example drawings than me, but the point is it works!

Erasing

To erase something, you can just change the Paint property of the current CanvasPath. By changing the blendMode of the Paint to BlendMode.clear, it causes to make an erase effect by dropping the source and the destination images on the canvas. What this means is that ti will drop the previously drawn image and the new one, leaving nothing. In order for blend mode to do this, we need to save the previous layer that has been drawn with canvas.saveLayer(), and call canvas.restore() afterwards which enables the new drawing to be composed into the previous one, therefore effects such as changing blend modes are possible.

Some parts erased

The great thing about this is that changing the stroke width also changes the “eraser” since it is just another CanvasPath, and also the erase changes can be undone for the same reasons!

Thanks for reading this article ❤

Clap 👏 If this article helped you in any way, and feel free to leave comments, questions, and improvements.

--

--

Ivan Štajcer

I am a mobile application developer mostly using Flutter and Swift