How to draw a custom icon in Flutter

Sviatoslav
6 min readMay 22, 2024

--

Hi!

Today, I’m going to show you how to draw a heart icon in Flutter.

First of all, you may know that Flutter has a set of icons that you can use for various purposes. For instance, there’s the Icons.favorite icon.

Icons.favorite

While it looks good, it’s also so overused, and we’ve seen it thousands of times in apps. So let’s create something beautiful and unique ourselves. In this article, we will create three types of hearts: “Outlined”, “Red”, and “Gradient”.

Three hearts

How to draw in Flutter

As you know, all UI elements in Flutter, including every button and every text, are drawn on a canvas. Each widget that displays something on the screen contains instructions on how to draw it on the canvas using primitives.

Flutter developers have made our lives easier by creating a vast number of widgets for every occasion. However, sometimes we need something custom while developing applications. In such cases, two classes come to our rescue: CustomPaint and CustomPainter.

CustomPaint is a widget where you can set your own instructions for what should be drawn on the canvas.

CustomPainter is the class that actually contains these instructions.

Here is a simple example of how to draw a rectangle using CustomPaint and CustomPainter.

Run the code

Let’s dive into the details. As you can see, a CustomPaint widget has been added to the widget tree with two parameters:

  • painter — An object that contains instructions on what and how to draw.
  • size — Widget dimensions. This is an optional parameter; the size can be determined by the parent or child widget

Starting from line 39, a class is declared that is a child of CustomPainterRectPaint. It has two methods:

  • void paint(Canvas canvas, Size size) — this method is called every time an element needs to be drawn on the canvas.
  • void shouldRepaint(covariant CustomPainter oldDelegate) — this method determines whether an element should be redrawn.

About shouldRepaint and Optimization

Flutter decides whether to call the paint method or use its previous result basing off many parameters. We can conditionally divide these parameters into two types:

  • External factors — for example, changing the size of the container.
  • Internal factors — for example, if our CustomPainter was specified with a color, and the color has changed, it is necessary to redraw. Each redraw adds additional load, so avoid it whenever possible, and use it whenever absolutely necessary. However, for when your interface is not complex, remember: premature optimization is the root of all evil. It’s better to make an MVP of your application and then improve it, rather than never finish it, aiming for perfect performance from the start.

For now, we will omit this function and will just set the return value as true or false for simplicity in this article.

The paint Method

The paint method contains two parameters:

  • Canvas canvas — this is the canvas on which we draw.
  • Size size — this is the size of the surface on which we are drawing.

The Canvas class has several methods for drawing different shapes, such as lines, rectangles, ovals, and paths. Most of these methods require data about what we are drawing and how we are doing it. The Paint object is responsible for the latter.

In our example, a blue filled square is displayed on the screen. To make it blue and filled, we’ve created a Paint object like this:

final paint = Paint()
..color = Colors.blue // set a color
..style = PaintingStyle.fill; // set painting style

Next, we need to draw the square itself using the coordinates and the Paint object that we made earlier.

/// Create a rect based on size of canvas
final rect = Rect.fromLTWH(0, 0, size.width, size.height);

/// Draw the rect on the canvas using Paint object
canvas.drawRect(rect, paint);

Now that we understand how rendering works, let’s try to make the square not blue, but filled with a yellow-blue gradient. To do this, we need to slightly modify the Paint object. Instead of setting a color, we'll specify the desired gradient:

final paint = Paint()
..style = PaintingStyle.fill // keep this style of painting
..shader = const LinearGradient( // create a gradient
colors: [Colors.lightBlue, Colors.yellow], // set colours
).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) // Create a shader based on LinearGradient;

Run the code

CustomPaint + RectPainter + Gradient

Stroke

The Paint object can not only fill the shapes we've drawn, but also stroke them. To do this, let's change the Paint settings:

final paint = Paint()
..style = PaintingStyle.stroke // Change the style to stroke
..strokeWidth = 5 // Set the stroke's width
..shader = const LinearGradient(
colors: [Colors.lightBlue, Colors.yellow],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));

Run the code

CustomPaint + RectPainter + Gradient + PaintingStyle.stroke

Here, we changed the painting style and set an additional parameter, strokeWidth, which defines the width of the stroke. Now, we have all three Paint objects that we can use to draw the hearts. Remember why we're here? 🙂.

Path

To draw shapes more complex than a rectangle, let’s use the Canvas.drawPath method. This method still takes Paint as a parameter, but in addition, it needs a Path. The official documentation defines Path as a complex one-dimensional subset of the plane. However, I offer you another definition:

Path is an object that contains a set of rules which allow us to draw any shape on a canvas. A Path can contain both primitive objects like lines and more complex ones like curves.

Here is the path needed to draw a heart:


final path = Path()
..moveTo(size.width / 2, size.height * 0.35)
..cubicTo(0.2 * size.width, size.height * 0.1, -0.25 * size.width,
size.height * 0.6, 0.5 * size.width, size.height)
..cubicTo(1.25 * size.width, size.height * 0.6, 0.8 * size.width,
size.height * 0.1, size.width / 2, size.height * 0.35)
..close();
The result: A heart drawn with Path

Run the code

At first glance, it looks like a pile of random numbers and weird method calls. However, it’s only partially true (a lil disclaimer for you: these numbers were generated by a local AI model since I’m too lazy to calculate them myself). But all the methods are correct. Here is a description for each:

  • Path.moveTo — this method sets the starting point of the path.
  • Path.close — this method connects the current point to the first point of the path.
  • Path.cubicTo — adds a cubic Bezier segment that curves from the current point to the given point (x3, y3), using the control points (x1, y1) and (x2, y2). To be honest, I couldn’t really wrap my head around it until I saw it.
The image from documentation

Here is a GIF showing how each method works step by step:

Drawing a heart line by line

The result

Now you have all the information you need to draw a heart like the one in the first picture of this article. Here is the full source code of the project:

Run the code

The hearts

Conclusion

Today, you’ve learned how to draw custom UI elements in Flutter using CustomPaint and CustomPainter, and you created beautiful heart icons! I hope you enjoyed this tutorial and found the results satisfying. And give me a few claps, if haven't done that yet 😊.

If you want to learn more about custom painting in Flutter, it’s time to dive into the official documentation. Start here: CustomPaint and explore as deeply as you need.

Bye.

--

--