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.

4.4 (10) · 4 Reviews

Download materials
Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

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.

Drawing multiple paths preview

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.

Drawing multiple paths preview

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 DrawnLines. All of these DrawnLines 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 DrawnLines – 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.

Drawing multiple paths preview

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 CustomPaints. 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 StreamBuilders instead of calling setState() repeatedly. StreamBuilders will allow you to rebuild parts of the UI selectively — not all of it at once. You’ve defined two StreamControllers 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.

Drawing multiple paths preview