Skip to content
Blog

Build a Doodle Drawing App in Flutter: Strokes, Undo/Redo, and PNG Export

A complete doodle app in Flutter that also runs on the web: stroke model, gesture capture, undo/redo, color and width picker, clear, and PNG export.

Published on • November 11, 2026

AI Assistant

A doodle app is the shortest path from “I know CustomPainter” to “I shipped a real feature”: strokes, gestures, history, a toolbar, and an export button. This post builds the whole thing — a sketch pad that behaves the same on a phone and in the browser, with undo/redo, a color and stroke-width picker, clear, and PNG export — and calls out the web-specific behavior at each step.

Model strokes as data

Everything downstream depends on this choice: a stroke is a record of points plus the style it was drawn with, and the canvas is a fold over the stroke list. Keeping committed strokes separate from the in-flight one is what makes undo cheap — history becomes snapshots instead of a replay of pointer events.

class Stroke {
  Stroke(this.color, this.width);
  final Color color;
  final double width;
  final List<Offset> points = <Offset>[];
}
List<Stroke> _strokes = []; // committed strokes
List<Stroke>? _active;      // the stroke currently being drawn
int _version = 0;           // bumped on every setState

Capture the gesture

GestureDetector gives three callbacks that map onto a pen touching paper: onPanStart begins a stroke, onPanUpdate appends points, onPanEnd commits it. Pan rather than drag, because a doodle has no axis constraint, and details.localPosition is already in the painter’s coordinate space.

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onPanStart: (d) => setState(() {
    _version++;
    _active = Stroke(_color, _width)..points.add(d.localPosition);
  }),
  onPanUpdate: (d) => setState(() {
    _version++;
    _active?.points.add(d.localPosition);
  }),
  onPanEnd: (d) => setState(() {
    _version++;
    _pushHistory(); // snapshot BEFORE the commit lands in _strokes
    _strokes.add(_active!); _active = null;
  }),
  child: RepaintBoundary(
    key: _canvasKey,
    child: CustomPaint(painter: DoodlePainter(_strokes, _active, _version)),
  ),
)

Paint the strokes

The painter walks every stroke, turns its points into a Path, and strokes it with round caps so segments join without gaps. One subtlety: the live stroke is mutated in place, so comparing list identity would tell shouldRepaint nothing changed while you draw — that is what the version counter is for.

class DoodlePainter extends CustomPainter {
  DoodlePainter(this.strokes, this.active, this.version);
  final List<Stroke> strokes;
  final Stroke? active;
  final int version;
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white);
    for (final s in <Stroke>[...strokes, if (active != null) active!]) {
      final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
      for (final p in s.points.skip(1)) path.lineTo(p.dx, p.dy);
      canvas.drawPath(path, Paint()
        ..color = s.color
        ..strokeWidth = s.width
        ..strokeCap = StrokeCap.round
        ..style = PaintingStyle.stroke);
    }
  }
  @override
  bool shouldRepaint(DoodlePainter old) =>
      old.version != version || old.strokes.length != strokes.length;
}

Undo, redo, and clear

History is two stacks of snapshots. Push the current state before mutating and undo becomes a swap rather than a diff; clear is just an undoable empty state. Wire all three to toolbar buttons next to a color picker and a width slider, disabling each when its stack is empty.

final List<List<Stroke>> _undo = [];
final List<List<Stroke>> _redo = [];
void _pushHistory() {
  _undo.add(List<Stroke>.of(_strokes));
  _redo.clear(); // a new stroke invalidates the redo branch
}
void _move(List<List<Stroke>> from, List<List<Stroke>> to) {
  if (from.isEmpty) return;
  setState(() {
    _version++;
    to.add(List<Stroke>.of(_strokes));
    _strokes = from.removeLast();
  });
}
void _undoAction() => _move(_undo, _redo);
void _redoAction() => _move(_redo, _undo);
void _clear() {
  setState(() { _version++; _pushHistory(); _strokes = []; });
}

Export the canvas as PNG, including on web

RepaintBoundary gives the canvas its own layer, which is exactly what toImage needs to snapshot; pass the display’s devicePixelRatio as pixelRatio or the export comes out at CSS resolution and looks soft on any HiDPI screen. On web there is no file system, so the PNG becomes a download anchor built with package:web.

Future<void> _exportPng() async {
  final boundary =
      _canvasKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
  final image = await boundary.toImage(
      pixelRatio: MediaQuery.devicePixelRatioOf(context));
  final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  final bytes = byteData!.buffer.asUint8List(); // Uint8List -> base64 -> URL
  final anchor = web.HTMLAnchorElement()
    ..href = 'data:image/png;base64,${base64Encode(bytes)}'
    ..download = 'doodle.png'
    ..click(); // mobile: save via path_provider or share via share_plus
}

A mouse drag produces the same pan events as a touch drag, so the gesture code needs no branching, but three web details do: claim the canvas with HitTestBehavior.opaque so drags are won by the drawing surface rather than a parent scrollable; keep touch-action: none on the element hosting the Flutter canvas if you embed it in your own scrolling container, or touch drags get swallowed by the browser’s scroll gesture; and read devicePixelRatio at export time, since it differs per monitor — a 800×600 CSS canvas becomes a 1600×1200 PNG on a 2x display. For large drawings, swap the base64 data URL for a Blob object URL to avoid one giant string allocation.

Takeaway: Model strokes as point lists, capture them with pan gestures, repaint through a version-checked CustomPainter, keep undo/redo as snapshot stacks, and export via RepaintBoundary.toImage with devicePixelRatio — the same code then runs untouched in the browser.

Sources