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 3 of 5 of this article. Click here to view the first page.

Diving Into Code

Now you know the basics of drawing simple shapes on the canvas, and so it’s time to start working on your drawing app. Exit this sample app and run the drawing app by using the command flutter run.

Then, you’ll start work on your app by drawing a simple Path using Sketcher — a CustomPainter class for this project located in lib/sketcher.dart — in combination with CustomPaint. You’ll use all of this together with GestureDetector to find the coordinates of the points that the user touches.

Using GestureDetector

Start by implementing buildCurrentPath() in lib/drawing_page.dart:

GestureDetector buildCurrentPath(BuildContext context) {
  return GestureDetector(
    onPanStart: onPanStart,
    onPanUpdate: onPanUpdate,
    onPanEnd: onPanEnd,
    child: RepaintBoundary(
      child: Container(
        color: Colors.transparent,
        width: MediaQuery.of(context).size.width,
        height: MediaQuery.of(context).size.height,
        // CustomPaint widget will go here
      ),
    ),
  );
}

In the code above, you return GestureDetector from buildCurrentPath(). You use GestureDetector‘s onPanStart(), onPanUpdate() and onPanEnd() events to detect the touches — and specifically the dragging — on the screen. You also use RepaintBoundary to optimize the redrawing.

The next step is to implement the three missing methods. Create the methods as shown below:

void onPanStart(DragStartDetails details) {
  print('User started drawing');
  final box = context.findRenderObject() as RenderBox;
  final point = box.globalToLocal(details.globalPosition);
  print(point);
}

void onPanUpdate(DragUpdateDetails details) {
  final box = context.findRenderObject() as RenderBox;
  final point = box.globalToLocal(details.globalPosition);
  print(point);
}

void onPanEnd(DragEndDetails details) {
  print('User ended drawing');
}

In the code snippet above:

  • onPanStart() is executed when the user touches the screen and starts dragging their finger around it.
  • When the user is dragging their finger on the screen without lifting it off the screen, the app executes onPanUpdate().
  • onPanEnd() is executed when the user lifts their finger off the screen.

To find RenderBox for GestureDetector, you used findRenderObject(). You also used globalToLocal() to convert the global coordinates to the local coordinates you’ll use to draw the path.

For now, you are printing the points the user touches on the screen to the console, to ensure that the detection works as expected.

Finally, add buildCurrentPath() to Stack in the main build().

...
  Stack(
    children: [
      // Add this
      buildCurrentPath(context),
    ],
  ),

Save everything and hot restart. Touch the screen and you’ll see some logs on the console similar to the ones shown below.

I/flutter (21819): User started drawing
I/flutter (21819): Offset(157.5, 305.5)
I/flutter (21819): Offset(157.5, 305.5)
...
I/flutter (21819): Offset(158.9, 362.2)
I/flutter (21819): User ended drawing

Drawing a Single Path

Combining the three methods above will give us coordinates of all the points the user touches with their screen in one go, without lifting their finger. Next, you need to create a DrawnLine using these three methods.

Modify the three methods as shown below:

void onPanStart(DragStartDetails details) {
  ...  
  setState((){
    line = DrawnLine([point], selectedColor, selectedWidth);
  });
}

void onPanUpdate(DragUpdateDetails details) {
  ...
  final path = List.from(line.path)..add(point);
  setState((){
    line = DrawnLine(path, selectedColor, selectedWidth);
  });
}

void onPanEnd(DragEndDetails details) {
  setState((){
    print('User ended drawing');
  });
}

Here’s what the code above does:

  • Inside onPanStart(), you create a new DrawnLine and use the only point you have to create DrawnLine. Additionally, you use selectedColor for the color, and selectedWidth for the stroke width. Both of these have default values. Then, you call setState() to update the UI.
  • Inside onPanUpdate(), you create a path that’s a type of List<Offset>, add new points to the list and update the line. Finally, you call setState() to update the UI.

To see the DrawnLine line, you need a CustomPaint inside buildCurrentPath(). Add the following code as the Container‘s child and import the missing file – sketcher.dart.

GestureDetector buildCurrentPath(BuildContext context) {
  return GestureDetector(
    ...
    child: RepaintBoundary(
      child: Container(
        ...
        child: CustomPaint(
          painter: Sketcher(lines: [line]),
        ),
      ),
    ),
  );
}

In the code snippet above, you add a CustomPaint widget. For the painter parameter, you pass in a Sketcher instance. It takes in the lines property to which you pass in a List containing the DrawnLine that you created using gesture events.

Save all the files and hot restart, then try drawing with your finger on the screen.

Drawing single path preview

Awesome, right? :]

Drawing Multiple Paths

Right now, when you draw a new path the older path just disappears. This happens because you reinitialize the line inside onPanStart(). As soon as you touch the screen, you lose the old path.

To draw multiple paths on the screen, you’ll need to store all the path points. You won’t always reinitialize the line inside onPanStart(). You only have to initialize it if the user is drawing the very first path.

When a path ends you’ll insert a null value instead of an Offset as the point, to know you need to initialize a new starting point.

Here are the changes you need to apply to GestureDetector events:

void onPanStart(DragStartDetails details) {
  print('User started drawing');
  ...
  if (line == null) {
    line = DrawnLine([point], selectedColor, selectedWidth);
  }
  ...
}

void onPanUpdate(DragUpdateDetails details) {
  ...
}

void onPanEnd(DragEndDetails details) {
  final path = List.from(line.path)..add(null);
  setState(() {
    line = DrawnLine(path, selectedColor, selectedWidth);
  });
}

Here’s what’s happening in the code above:

  • Inside onPanStart(), you initialize line only if the line object is null. This means that it is only initialized when the user touches the screen for the first time to draw the very first path. This prevents the line from being cleared on each onPanStart() event.
  • There are no changes in onPanUpdate().
  • Inside onPanEnd, you insert an Offset with a null value to mark the end of path. The Sketcher takes these null values into account and draws paths accordingly.

Save everything and hot restart. Try drawing on the screen.

Drawing multiple paths preview

If you keep drawing, you’ll see that you’re able to draw multiple paths. It is actually a single path that is broken in various places with the help of null values.

Adding Stroke Color and Width

If you scroll to the top in lib/drawing_page.dart, you’ll find two variables defined: selectedColor and selectedWidth:

final selectedColor = Colors.black;
final selectedWidth = 5.0;

These are the values you pass to DrawnLine. This is the reason behind the default color and width of the paths you have been drawing so far.