Drawing Custom Shapes in Flutter using CustomPainter

Brief intro about Flutter

Flutter is a Mobile UI framework by Google which allows developers to create beautiful apps in record time for both iOS and Android with a single codebase. Most talked about feature that Flutter comes with is “Hot Reload” that allows blazing fast reload times (without actually loosing the current state of the app)

This article assumes that you are already familiar with or at least have an understanding of building a basic Flutter App with simple nested widgets.

Custom Shapes

Apart from inbuilt or trivial widgets that Flutter Framework provides like a Text Widget, a RaisedButton or a Container for bare bones what if we want to draw a custom button that has a custom shape or you just simply want to bring out that inner artist in you, Flutter makes it a breeze to draw these custom artistic shapes.

Custom Lines draw using CustomPainter in flutter.

Custom Lines

Custom Shapes and Curves drawn using CustomPainter in Flutter

Custom Shapes and Custom Curves

Let’s get started with making some simple custom shapes

Step 1: Create a new Flutter Project

Run the following command in your Terminal/Command prompt

flutter create custom_shapes

This will create a new flutter project and set up all the dependencies for you. After its done just open the folder in your preferred code editor. I will be using VSCode throughout this article for this project.

You can see the basic material app code that flutter generated for you in the lib/main.dart file. To run the app in emulator or your phone just goto Command Pallet (Command/Control + Shift + P) and type “Flutter: Select Device” and choose the device in which you would like to run the app. Goto Debug -> Start Without Debugging to start compiling and running the app on the chosen device.

Selecting the device option from the VSCode Command Pallete

Flutter: Select Device

Selecting the Device on which the app will run on from the list of devices connected in VSCode

List of Emulators or devices connected

Start the app without debugging option in VSCode to run the flutter app

Running the app on chosen device

Step 2: Creating a Base UI

Let’s remove all the unwanted code from the main.dart file and start from the beginning with a bare minimum app screen that has a simple scaffold with an AppBar having the title.

import 'package:flutter/material.dart';

void main() => runApp(HomePage());

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
brightness: Brightness.dark,
accentColor: Colors.deepOrangeAccent,
),
home: Scaffold(
appBar: AppBar(
title: Text('Custom Shapes'),
),
body: Padding(
padding: EdgeInsets.all(8.0),
child: Container(),
),
),
);
}
}
Initial App screen when its run with all the widgets removed and just having a MaterialApp and a Scaffold with Container
Initial Screen

Step 3: Let’s draw something

We will make use CustomPaint Widget which allow us to draw things on the screen by making use of a CustomPainter object.

CustomPaint(
painter: MyCustomPainter(),
child: Widget(),
....
)

CustomPaint widget requires mainly two things, a painter and a child widget. The Custom paint uses the painter to paint/draw (ex: custom shapes) on the canvas after which it draws the child widget on top of it. Let’s add this CustomPaint Widget to our app and start drawing something.

body: Padding(
padding: EdgeInsets.all(8.0),
child: CustomPaint(
painter: ShapesPainter(),
child: Container(height: 700,),
),
),

ShapesPainter is an instance of a class that extends CustomPainter. The CustomPainter class provides us with 2 methods to override.

class ShapesPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// TODO: implement paint
}

@override
bool shouldRepaint(CustomPainter oldDelegate) {
// TODO: implement shouldRepaint
return null;
}
}

The shouldRepaint method is used to optimise repaints and basically tell whether the widget needs to be repainted if this properties change. You can read more about that here.

The paint method is where the real magic happens. This method comes with two parameters. A canvas and the Size of the canvas (or boundaries). We draw stuffs on this Canvas using a Paint object which can be created and customised as required. The paint can have various properties like color, shader, style etc. more about it here.

So to just sum it all up, we use a paint (more like a brush) and draw stuffs on the Canvas that we have been provided with which has some width and height. It’s similar to how we draw something on a piece of paper using a pencil. Here the paper would have definite width and height (that would be the Canvas object here) and the pencil that we used to draw might have different levels of darkness and color etc. (that would be our Paint object here).

The canvas object comes with some helper methods like drawCircle, drawRect etc. to draw a circle and draw a rectangle. All the draw..() methods in the canvas requires a paint object.

Let’s create a simple paint object which has a color of Colors.deepOrange and draw a circle on the canvas.

class ShapesPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
// set the color property of the paint
paint.color = Colors.deepOrange;

// center of the canvas is (x,y) => (width/2, height/2)
var center = Offset(size.width / 2, size.height / 2);

// draw the circle on centre of canvas having radius 75.0
canvas.drawCircle(center, 75.0, paint);
}

@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
A Custom Orange circle drawn on the middle of the canvas
Custom Drawn Circle at the center of canvas

canvas.drawCircle(…) method takes in three parameters, the centre of the circle, the radius and the paint object to be used. Here, we draw the circle at the centre of the canvas. We find the centre using size.width and size.height from the Size object which returns the width and height of the canvas.

The shouldRepaint method here just returns false since we don’t have any fields/values that might change and influence the custom shapes that we draw.

Now let’s draw a simple white rectangle which spans to the entire width and height of the canvas. We can use the canvas.drawRect(..) method to do so. The drawRect() method takes Rect object and a paint. We can create an instance of Rect object in various ways. We’ll use Rect.fromLTWH(), where LTWH stands for Left, Top, Width and Height which means the initial (x,y) point that we start from or the left-topmost point of the rectangle and the width and height of the rectangle that would be added to left and top points which forms the required rectangle.

@override
void paint(Canvas canvas, Size size) {
final paint = Paint();

// set the color property of the paint
paint.color = Colors.deepOrange;

// center of the canvas is (x,y) => (width/2, height/2)
var center = Offset(size.width / 2, size.height / 2);

// draw the circle with center having radius 75.0
canvas.drawCircle(center, 75.0, paint);

// set the paint color to be white
paint.color = Colors.white;

// Create a rectangle with size and width same as the canvas
var rect = Rect.fromLTWH(0, 0, size.width, size.height);

// draw the rectangle using the paint
canvas.drawRect(rect, paint);
}
Custom White Rectangle drawn which spans to the canvas width and height but its drawn above the orange circle
White Rectangle

But wait, where is the circle?

Well, the circle hasn’t gone anywhere. It’s just hidden behind the rectangle. This brings up an important characteristic of drawing using CustomPainter here. The order how you write the draw commands matter. If you look at the code closely, the drawCircle() function is called first and after that the drawRect(). So, the circle will be drawn on the canvas first and then the rectangle. We can easily fix (since we want the circle on the rectangle) that by just moving the drawCircle() function after the drawRect().

@override
void paint(Canvas canvas, Size size) {
final paint = Paint();

// set the paint color to be white
paint.color = Colors.white;

// Create a rectangle with size and width same as the canvas
var rect = Rect.fromLTWH(0, 0, size.width, size.height);

// draw the rectangle using the paint
canvas.drawRect(rect, paint);

// set the color property of the paint
paint.color = Colors.deepOrange;

// center of the canvas is (x,y) => (width/2, height/2)
var center = Offset(size.width / 2, size.height / 2);

// draw the circle with center having radius 75.0
canvas.drawCircle(center, 75.0, paint);
}
The Orange circle drawn over the white rectangle after fixing the code.
Orange Circle over the white rectangle

Looks good! Now let’s draw a custom path and wrap this up!

To draw a custom path we’ll use drawPath() method from the canvas. We’ll draw a path from top-left(0,0) to bottom-left(0, size.height) to top-right(size.width, 0) which forms a simple triangle.

To do this we’ll have to use a Path object to represent the path that we draw. The way we draw the path is quite simple. Whenever we initialise a path object i.e path = Path(), the initial point defaults to (0,0) i.e top-left off the screen. The coordinate system of the canvas looks something like this.

Coordinate system in the Flutter canvas
Coordinate system

We can use the lineTo() method to create a line from (x,y) to (x1, y1). Here it would be from (0,0) which is initialised by default, to (0, size.height) which is the bottom-left of the canvas. Finally we use close() to close the path which will forms a triangle.

paint.color = Colors.yellow;

// create a path
var path = Path();
path.lineTo(0, size.height);
path.lineTo(size.width, 0);
// close the path to form a bounded shape
path.close();
A yellow path drawn on canvas that makes up a triangle
Yellow path

Let’s move the code that draws the circle to the end so that it’s layered on top of all the other shapes.

Here’s the entire code.

import 'package:flutter/material.dart';

void main() => runApp(HomePage());

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
brightness: Brightness.dark,
accentColor: Colors.deepOrangeAccent,
),
home: Scaffold(
appBar: AppBar(
title: Text('Custom Shapes'),
),
body: Padding(
padding: EdgeInsets.all(8.0),
child: CustomPaint(
painter: ShapesPainter(),
child: Container(
height: 700,
),
),
),
),
);
}
}

class ShapesPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();

// set the paint color to be white
paint.color = Colors.white;

// Create a rectangle with size and width same as the canvas
var rect = Rect.fromLTWH(0, 0, size.width, size.height);

// draw the rectangle using the paint
canvas.drawRect(rect, paint);

paint.color = Colors.yellow;

// create a path
var path = Path();
path.lineTo(0, size.height);
path.lineTo(size.width, 0);
// close the path to form a bounded shape
path.close();

canvas.drawPath(path, paint);

// set the color property of the paint
paint.color = Colors.deepOrange;

// center of the canvas is (x,y) => (width/2, height/2)
var center = Offset(size.width / 2, size.height / 2);

// draw the circle with center having radius 75.0
canvas.drawCircle(center, 75.0, paint);
}

@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Final app with a white rectangular background and yellow triangle path and an orange circle on top all of which drawn on the canvas.
Final App with custom shapes

Play around with the code and try new shapes and colors and what not.

You can find the code here in this GitHub repo.

🤤