UIKit Apprentice, Second Edition – Now Updated!

Learn iOS and Swift from scratch. Build four powerful apps—with support for iPad and Dark Mode. Publish apps to the App Store.

Home Flutter Tutorials

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.

5/5 2 Ratings

Version

  • Dart 2.12, Flutter 2.2, Android Studio 4.2

Drawing the UI is one of the core features of every visual app framework. In this tutorial, you’ll build a drawing app in Flutter to explore Flutter’s capability to render and control a custom user interface. You’ll learn to control every pixel on the screen with the help of CustomPaint widgets.

Here’s a preview of the app you’ll build:

Drawing App Final Preview

To build this drawing app, you’ll harness the rendering powers and the pixel-level control that Flutter offers. It allows you to control each pixel on the screen — you can render anything you want, any where you want.

While building this app, you’ll learn about:

  • Using the CustomPaint widget to draw UI pixel-by-pixel
  • The drawing basics and using a Canvas
  • Detecting user input using GestureDetector and drawing a path
  • Drawing multiple paths on the screen
  • Changing painter colors and stroke widths
  • Clearing and saving your drawings
Note: This article assumes that you know the basics of the Flutter framework, including common widgets and their properties. If you want to learn how to draw basic shapes in Flutter, have a look at Drawing Custom Shapes with CustomPaint in Flutter tutorial. To learn more about Flutter from scratch, check out the Flutter Apprentice book.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

Unzip the downloaded file and open it with Android Studio 4.1 or later. You can use Visual Studio Code instead, but if you do, you’ll need to tweak some instructions to follow along.

Click Open an existing Android Studio project and choose the starter folder from your unzipped download.

Run the flutter create . command in the starter folder to generate the android and ios folders. Next, download your dependencies by double-clicking pubspec.yaml on the left panel, then clicking pub get at the top of your screen. To avoid problems, delete the test folder, which Flutter generated when you executed the flutter create . command.

Finally, build and run to see this:

Starter project on iOS Simulator

Here are a few files you’ll see in the starter project’s lib folder.

  • main.dart: This is the main file that acts as the entry point for the app. It contains a MyApp that contains MaterialApp. This widget uses DrawingPage as the child.
  • drawing_page.dart: This file contains most of the code. It renders the widgets that you saw in the app preview. The drawing area, the save and clear buttons, the color toolbar and the stroke toolbar are all rendered inside DrawingPage.
  • drawn_line.dart: This file is a simple model class DrawnLine for a typical path drawn on the screen. Each path that is drawn contains a List of points on the screen (List<Offset>), a color (Color) and a stroke width (double). You’ll use this class when drawing paths. “Path” might be a more relevent name for this class, but since it’s already a Flutter class, you use the name DrawnLine. Feel free to rename it, but be sure to change the rest of the code accordingly.
  • sketcher.dart: Sketcher is a class that extends CustomPainter and is used in combination with the CustomPaint to draw on the screen. This class contains the logic to draw DrawnLines on the screen.
  • main_learning.dart: You’ll use this file to show the basics of drawing simple shapes on the Canvas.

Here’s what your app’s user interface (UI) will look like:

Final app UI description

  • Drawing Space: This is the light yellow space where the user will draw with their finger.
  • Color Toolbar: This allows the user to change the currently selected color to one of seven colors.
  • New/Clear Button: This button allows the user to clear the canvas and start afresh.
  • Save Button: This lets the user save whatever is currently drawn on the screen as an image on the device. The saved image appears in the phone’s Gallery (on Android) and Photos (on iOS).
  • Stroke Toolbar: This contains buttons that allow the user to change the current stroke width to a specific size. There are three stroke sizes — small, medium and large.
Note: Even though the component is called ColorToolbar, it also contains the New/Clear buttons.

Introducing Flutter Canvas and CustomPaint

Flutter’s Canvas is an interface for recording graphical operations. It can be used to draw shapes, images, texts and nearly everything else on the screen with pixel precision. To create and access the Canvas, you’ll use a widget called CustomPaint. You’ll also use a painter parameter that contains all the painting logic and extends CustomPainter.

Using the CustomPaint Widget

Here’s a code snippet that includes a Container and has a CustomPaint as its child:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.yellow[100],
      child: CustomPaint(
        painter: MyCustomPainter(),
      ),
    );
  }
}

In the code snippet above, you create a widget called MyWidget that renders a Container. The Container contains a CustomPaint as its child. Have a quick look at the most simple implementation of MyCustomPainter:

// 1
class MyCustomPainter extends CustomPainter {
  // 2
  @override
  void paint(Canvas canvas, Size size) {
  }

  // 4
  @override
  bool shouldRepaint(MyCustomPainter delegate) {
    return true;
  }
}

Here’s what’s going on in the code snippet above:

  1. You create the MyCustomPainter, which extends CustomPainter. Once you create the class with this signature, your IDE will detect errors and suggest you implement the two missing methods – paint() and shouldRepaint().
  2. paint() contains all the drawing and painting logic. It receives two arguments: Canvas and Size. You’ll use both of these while writing the drawing code. All the drawing code goes within the body of this method, where you’ll gain access to various methods like drawLine(), drawArc(), drawPath(), drawPoints(), drawRect() and many more. To find all available methods, see the official Flutter documentation. The Size parameter allows you to draw considering the size of the canvas.
  3. shouldRepaint() is an optimization method that’s called whenever you create a new CustomPaint. If the new instance represents different information than the old one, the method returns true. Otherwise, it returns false.

The Sketcher (in sketcher.dart) available in the starter project is a similar class that extends CustomPainter.

Understanding Canvas Basics

The Canvas is available in paint() of the CustomPainter‘s subclass. It’s used to draw on the canvas, which has certain dimensions. Those are also available to you inside paint() as the second argument, size. The size of the parent widget limits the size of the canvas.

In the starter project, the whole screen will be occupied by CustomPaint, so the size of the canvas is the same as the size of the screen of the device the app is running on.

Within a canvas, you specify each position on the screen — or rather, each point — with the help of coordinates. Here’s what the coordinates look like for an iPhone X that has a width of 375.0 px and a height of 812.0 px.

Flutter Canvas Coordinate System

Here’s an explaination of the image above:

  • The top-left corner has the coordinates (0,0), which means the coordinates along the x-axis and the y-axis are both 0.
  • The top-right corner has the coordinates (375,0). This means the coordinate along the x-axis is 375 but along the along the y-axis is still 0.
  • The bottom-left corner has the coordinates (0,812), which means the coordinate along the x-axis is 0 but along the y-axis is 812.
  • The bottom-right corner has the coordinates (375,812). This means the coordinate along the x-axis is 375 and along the y-axis is 812.
  • The center of the screen has the coordinates (187.5,406).

By using coordinates, you can draw various shapes on the screen. Consider a simple implementation of paint() that draws a line on the canvas. If you want to try it by yourself, replace the functionality of paint() located in lib/main_learning.dart with the code below:

@override
  void paint(Canvas canvas, Size size) {
    // 1
    Offset startPoint = Offset(0, 0);
    // 2
    Offset endPoint = Offset(size.width, size.height);
    // 3
    Paint paint = Paint();
    // 4
    canvas.drawLine(startPoint, endPoint, paint);
  }

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

  1. You create an Offset startPoint and use the top-left coordinates of the screen, which will always be (0,0).
  2. Next, you create another Offset endPoint and use the bottom-right coordinates of the screen. Since you do not know the size of the screen, you use size‘s width and height properties.
  3. As you have the startPoint and the endPoint ready, you need a Paint, which specifies cosmetic properties of a drawn line. For now, you do nothing special here.
  4. Finally, you use drawLine to draw a line from startPoint to endPoint using the specified Paint.

If you replaced the code in the paint, save the changes and run by writing command flutter run -t lib/main_learning.dart in the terminal. You’ll see a thin line drawn from the top-left to the bottom-right of the screen.

Starter project on iOS Simulator

Drawing Paths

Paths in Flutter are a way to draw arbitrary shapes on the screen. It’s as simple as creating a path and then using methods like lineTo(), moveTo(), addOval(), addArc(), addPolygon() etc., to get the desired shape on the canvas. Have a look at another fun implementation of paint() — but this time, draw a Path. Replace the functionality of paint(), located in lib/main_learning.dart, with the code below:

void paint(Canvas canvas, Size size) {
    // 1
    Paint paint = Paint()..style = PaintingStyle.stroke;
    // 2
    Path path = Path();
    // 3
    path.moveTo(0, 250);
    path.lineTo(100, 200);
    path.lineTo(150, 150);
    path.lineTo(200, 50);
    path.lineTo(250, 150);
    path.lineTo(300, 200);
    path.lineTo(size.width, 250);
    path.lineTo(0, 250);

    // 4
    path.moveTo(100, 100);
    path.addOval(Rect.fromCircle(center: Offset(100, 100), radius: 25));

    // 5
    canvas.drawPath(path, paint);
  }

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

  1. You create a Paint and set the PaintingStyle to PaintingStyle.stroke. This is because you only want to draw the paths and not fill the enclosed spaces with color.
  2. Next, you create a new Path using the default constructor.
  3. Finally, you use moveTo() and lineTo() to draw the path. It’s like moving a pen on the canvas from point to point — you create lines from one point to another. These lines will move in a zig-zag manner across the screen horizontally giving an impression of mountains.

    Here’s an explanation of all the points used in drawing the path:

    Explanation of the points used to draw the path

  4. Then you move the current drawing position to (100,100). This is where you want to draw a circle — the sun behind the mountains. Use moveTo() instead of lineTo() because while moving to (100,100), you do not want to draw a line. To create a circle with a radius of 25, use addOval().

    Explanation of the points used to draw the circle

  5. Finally, call drawPath() and pass in the path and the paint to draw the path.
Note: moveTo() only changes the current position, but lineTo() changes the current position and draws a line.

Save the changes and hot reload. Here’s what you’ll see on the screen:

Drawing App Final Preview

Changing the Stroke, Color and Width

So far, everything you drew on the canvas uses a very thin black stroke. You’ll change that by configuring Paint. Building on the previous example of mountains and sun, you’ll create two Paints: one for the sun, and the other for the mountains.

Change the paint() code to the following:

@override
  void paint(Canvas canvas, Size size) {
    // 1
    Paint paintMountains = Paint()
      ..style = PaintingStyle.fill
      ..color = Colors.brown;
    // 2
    Paint paintSun = Paint()
      ..style = PaintingStyle.fill
      ..color = Colors.deepOrangeAccent;

    // 3
    Path path = Path();
    path.moveTo(0, 250);
    path.lineTo(100, 200);
    path.lineTo(150, 150);
    path.lineTo(200, 50);
    path.lineTo(250, 150);
    path.lineTo(300, 200);
    path.lineTo(size.width, 250);
    path.lineTo(0, 250);
    canvas.drawPath(path, paintMountains);

    // 4
    path = Path();
    path.moveTo(100, 100);
    path.addOval(Rect.fromCircle(center: Offset(100, 100), radius: 25));
    canvas.drawPath(path, paintSun);
  }

Take a look at what this code does:

  1. By creating paintMountains, you set its color to brown and style to PaintingStyle.fill.
  2. sunMountains‘s color is set to deepOrangeAccent and style to PaintingStyle.fill.
  3. When drawing the path of the mountains, you use the paintMountains paint, and so the mountain shape is filled with the brown color.
  4. After resetting the path, you draw the path of the sun using the paintSun paint, which is why the sun shape is filled with the orange color.

Save the changes and hot restart. Here’s what the final output will look like:

Drawing App Final Preview

Isn’t it beautiful? And so simple to implement! :]

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.

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

Saving the Drawing

Now that you have a beautiful drawing, you’ll want to save it to show people later. The RepaintBoundary returned from buildAllPaths() will allow us to get the drawing and save it as an image.

Creating New and Save Buttons

While you create the Save button, you’ll also create the New/Clear button to clean the canvas so the user can start over. Add the following code to buildColorToolbar():

Widget buildColorToolbar() {
  return Positioned(
    ...
    child: Column(
      ...
      children: [
        buildClearButton(),
        Divider(
          height: 10.0,
        ),
        buildSaveButton(),
        Divider(
          height: 20.0,
        ),
        ...
      ],
    ),
  );
}

In the above code, buildClearButton() and buildSaveButton() return two buttons that you add to the color toolbar. Both these methods are already implemented for you.

Save everything and hot restart to see the new buttons in the color toolbar.

Save and Clear buttons added

Using the Plugin

The New/Clear and Save buttons are now there, but you still need to implement clear() and save(). You’ll start with implementing clear():

Future<void> clear() async {
  setState(() {
    lines = [];
    line = null;
  });
}

In the above code, the lines and line objects are reset to their initial values. This clears all the data about the past drawing, and the UI is rebuilt. The user will now be able to start a fresh drawing!

Finally implement save():

Future<void> save() async {
  try {
    final boundary = _globalKey.currentContext.findRenderObject() as RenderRepaintBoundary;
    final image = await boundary.toImage();
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    final pngBytes = byteData.buffer.asUint8List();
    final saved = await ImageGallerySaver.saveImage(
      pngBytes,
      quality: 100,
      name: DateTime.now().toIso8601String() + ".png",
      isReturnImagePathOfIOS: true,
    );
  } catch (e) {
    print(e);
  }
}

The code above uses the image_gallery_saver plugin to save the image to the device’s Gallery (Android) or Photos (iOS). You get the image as a Uint8List using RepaintBoundary. Notice how you have defined a key on the RepaintBoundaryWidget and used that key to access the widget in the method above.

At the top of the file, add the necessary imports listed in the code snippet below:

import 'package:flutter/services.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'dart:ui' as ui;

Save everything one last time and perform a complete restart.

Drawing multiple paths preview

That’s all! Congratulations — you now have an amazing drawing app that’s built entirely in Flutter.

Where to Go From Here

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

If you’re in the mood to take on a challenge or two, here are a few more features that you can implement in the drawing app you built:

  • Implement Undo/Redo features so that a user can go a step back or forward in their drawing process.
  • Add a color picker widget to allow the user to pick a color of their choice instead of being stuck with the provided buttons.
  • Create a widget to allow the user to select a precise stroke width.

Here are a couple of articles that will be helpful in learning more about widgets, drawing and animations in Flutter:

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Average Rating

5/5

Add a rating for this content

2 ratings

More like this

Contributors

Comments