Building a Drawing App in Flutter
Learn how to create a drawing app in Flutter and explore Flutter’s capability to render and control a custom UI with the help of CustomPaint widgets. By Samarth Agarwal.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Building a Drawing App in Flutter
35 mins
- Getting Started
- Introducing Flutter Canvas and CustomPaint
- Using the CustomPaint Widget
- Understanding Canvas Basics
- Drawing Paths
- Changing the Stroke, Color and Width
- Diving Into Code
- Using GestureDetector
- Drawing a Single Path
- Drawing Multiple Paths
- Adding Stroke Color and Width
- Changing Stroke Color
- Changing Stroke Width
- Optimizing Your App
- Drawing Multiple Lines
- Using StreamBuilders and Two CustomPaint widgets
- Saving the Drawing
- Creating New and Save Buttons
- Using the Plugin
- Where to Go From Here
Changing Stroke Color
buildColorToolbar()
will render the color toolbar on the screen. It consists of seven buttons, where each button sets the selectedColor
‘s value to a different color.
Add the following code to buildColorToolbar()
:
Widget buildColorToolbar() {
return Positioned(
top: 40.0,
right: 10.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildColorButton(Colors.red),
buildColorButton(Colors.blueAccent),
buildColorButton(Colors.deepOrange),
buildColorButton(Colors.green),
buildColorButton(Colors.lightBlue),
buildColorButton(Colors.black),
buildColorButton(Colors.white),
],
),
);
}
The code above is straightforward. It creates a Column
with seven buttons. To create a button, you use buildColorButton()
. When you tap one of the buttons, it updates the selectedColor
with the color of the button you tapped.
Finally, use the code below to add the buildColorToolbar()
to the Stack
in the main build()
:
...
body: Stack(
children: [
buildCurrentPath(context),
buildColorToolbar(), // Add this
],
),
...
Save everything and hot restart. Now, try to draw and change the color and draw again. You’ll see that color of the whole drawing changes.
The color of the whole drawing changes because the whole drawing is, technically, a single path. You’ll fix this later. First, you’ll add the options for different stroke widths.
Changing Stroke Width
To change the stroke width, you’ll have to create the stroke toolbar. It has three buttons that allow you to set the stroke width to one of the three predefined widths. Add the following code to buildStrokeToolbar()
:
Widget buildStrokeToolbar() {
return Positioned(
bottom: 100.0,
right: 10.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildStrokeButton(5.0),
buildStrokeButton(10.0),
buildStrokeButton(15.0),
],
),
);
}
In the code snippet above, you created a toolbar that’s similar to the color toolbar — but this time, you used buildStrokeButton
. When you tap the button, buildStrokeButton
takes a double
argument that’s set as the selectedWidth
.
Add buildStrokeToolbar()
to the Stack
in build()
:
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.yellow[50],
body: Stack(
children: [
buildCurrentPath(context),
buildColorToolbar(),
buildStrokeToolbar(), // Add this
],
),
);
}
Save everything and hot restart. Try drawing with different stroke sizes.
As you draw and change the stroke width, the stroke width of the whole drawing changes. Again, this happens because the whole drawing is technically a single path.
Optimizing Your App
Now that you have the basic drawing functionality up and running, it is time to optimize the drawing process. While you do that, you’ll also fix the issue that prevents each stroke from having its own color and width.
Drawing Multiple Lines
To allow each stroke the user draws to have its own color and width, you have to save each stroke as an individual DrawnLine
in a List
of DrawnLine
s. All of these DrawnLine
s in the list make up the complete drawing.
When the user starts drawing, you create a new DrawnLine
. When the user ends drawing, you insert this DrawLine
into the List
. While the user is drawing, you update the last object in the List
until the user lifts their finger.
Modify the implementation of onPanStart()
to the code shown below:
void onPanStart(DragStartDetails details) {
final box = context.findRenderObject() as RenderBox;
final point = box.globalToLocal(details.globalPosition);
setState(() {
line = DrawnLine([point], selectedColor, selectedWidth);
});
}
In the code above, you can see that you removed the if condition. Then, you reinitialized the line with a new DrawnLine
— essentially creating a new stroke on the screen.
Next, change onPanUpdate()
to update the last DrawnLine
in the List
or insert a new one if the list is empty:
void onPanUpdate(DragUpdateDetails details) {
final box = context.findRenderObject() as RenderBox;
final point = box.globalToLocal(details.globalPosition);
final path = List.from(line.path)..add(point);
line = DrawnLine(path, selectedColor, selectedWidth);
setState(() {
if (lines.length == 0) {
lines.add(line);
} else {
lines[lines.length - 1] = line;
}
});
}
As you can see, most of the code is unchanged. The only difference is that you update the last DrawnLine
in the list with the new points that the user touches while drawing. In an edge case, if the list is empty, you insert the line
in the list lines
.
Finally, you need to modify onPanEnd()
to add the newly created line to the List
of DrawnLine
s – lines
.
void onPanEnd(DragEndDetails details) {
setState(() {
lines.add(line);
});
}
The code above adds the line you created in onPanUpdate()
to the lines
list and refreshes the UI. Finally, you need to update the code to pass lines
instead of [line]
to Sketcher
in buildCurrentPath
.
Widget buildCurrentPath(BuildContext context) {
return GestureDetector(
...
child: RepaintBoundary(
child: Container(
...
child: CustomPaint(
painter: Sketcher(lines: lines), // changed [line] to lines
),
),
),
);
}
That’s all the refactoring you have to do for now. Save everything and hot restart. Try drawing on the screen with multiple colors and stroke sizes.
Using StreamBuilders and Two CustomPaint widgets
As of now, the UI is rebuilt whenever onPanStart()
, onPanUpdate()
or onPanEnd()
execute. Usually, this happens at a very high rate when you are using your finger to draw and onPanUpdate()
is called. It also gets expensive as you draw more and more, because the number of strokes increase — which in turn results in increased number of points on the screen. In other words, redrawing everything for every tiny update isn’t very efficient.
One way to deal with the situation is to use two CustomPaint
s. You use the first one to render the stroke currently being drawn and the other one to draw all previously drawn strokes (stored in the lines
list).
Then you’ll use StreamBuilder
s instead of calling setState()
repeatedly. StreamBuilder
s will allow you to rebuild parts of the UI selectively — not all of it at once. You’ve defined two StreamController
s on the top of the drawing_page.dart.
final linesStreamController = StreamController<List<DrawnLine>>.broadcast();
final currentLineStreamController = StreamController<DrawnLine>.broadcast();
You’ll use these instead of using setState()
. Now, implement buildAllPaths()
:
Widget buildAllPaths(BuildContext context) {
return RepaintBoundary(
key: _globalKey,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: StreamBuilder<List<DrawnLine>>(
stream: linesStreamController.stream,
builder: (context, snapshot) {
return CustomPaint(
painter: Sketcher(
lines: lines,
),
);
},
),
),
);
}
In the code above, you’re using a CustomPaint
to draw all the strokes stored in the lines
list. You’re using a StreamBuilder
that listens to the linesStreamController
‘s stream.
Next, modify the code in buildCurrentPath()
to implement StreamBuilder
:
Widget buildCurrentPath(BuildContext context) {
return GestureDetector(
...
child: RepaintBoundary(
child: Container(
...
child: StreamBuilder<DrawnLine>(
stream: currentLineStreamController.stream,
builder: (context, snapshot) {
return CustomPaint(
painter: Sketcher(
lines: [line],
),
);
},
),
),
),
);
}
The StreamBuilder
listens to currentLineStreamController
‘s stream.
Now refactor the code for onPanStart()
, onPanUpdate()
and onPanEnd()
:
void onPanStart(DragStartDetails details) {
...
currentLineStreamController.add(line);
}
void onPanUpdate(DragUpdateDetails details) {
...
currentLineStreamController.add(line);
}
void onPanEnd(DragEndDetails details) {
lines = List.from(lines)..add(line);
linesStreamController.add(lines);
}
You have essentially completely removed setState
. Finally, don’t forget to add buildAllPaths()
to the Stack
in build
.
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.yellow[50],
body: Stack(
children: [
buildAllPaths(context), // Add this
buildCurrentPath(context),
buildColorToolbar(),
buildStrokeToolbar(),
],
),
);
}
Save everything and hot restart. Everything works as expected now, with multiple strokes, colors and sizes.