# How to Build a Custom Image Editor in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 20-25 min
- Compatibility: FlutterFlow Pro+ (Custom Code required)
- Last updated: March 2026

## TL;DR

Build a drawing and annotation editor using a Custom Widget with the image_painter package. ImagePainter.memory(imageBytes) creates an interactive canvas where users draw with finger or mouse, add text, and place shapes. The toolbar provides pen, text, shape, color picker, stroke width, undo, and clear actions via ImagePainterController. Export the annotated result as PNG bytes with controller.exportImage() and upload to Firebase Storage.

## Building an Image Annotation Editor in FlutterFlow

Photo annotation is essential for document signing, marking up screenshots, and feedback workflows. This tutorial builds a full image editor Custom Widget with drawing tools, text placement, shapes, undo, and PNG export — all wired into FlutterFlow's visual builder.

## Before you start

- A FlutterFlow project on the Pro plan (Custom Code required)
- Firebase Storage enabled for saving annotated images
- An image source — either a Firebase Storage URL or a local asset to annotate

## Step-by-step guide

### 1. Add image_painter ^0.7.0 to Pubspec Dependencies

Open Settings → Custom Code → Pubspec Dependencies and add image_painter: ^0.7.0. This package provides an interactive canvas with built-in drawing, text, shapes, color picking, stroke control, undo/redo, and PNG export. It works on both web and mobile.

**Expected result:** The image_painter package appears in the dependency list and resolves without errors on next build.

### 2. Create the ImageEditorWidget Custom Widget with ImagePainter.memory

Create a new Custom Widget named ImageEditorWidget. Add a Component Parameter imageBytes (Uint8List) for the source image. In the build method, return a Column containing an Expanded child with ImagePainter.memory(widget.imageBytes, controller: _controller, scalable: true). Store the ImagePainterController as a class field so toolbar buttons can call its methods. Set width and height to double.infinity so the canvas fills its parent.

**Expected result:** The Custom Widget renders the source image on an interactive canvas that responds to touch and mouse drawing.

### 3. Wire up toolbar Row with pen, text, rectangle, circle, and line IconButtons

Below the Expanded ImagePainter, add a Row with mainAxisAlignment: spaceEvenly. Add five IconButtons: edit (pen), text_fields (text), crop_square (rectangle), circle_outlined (circle), show_chart (line). On each button tap, call _controller.setMode(PaintMode.freeStyle), _controller.setMode(PaintMode.text), etc. Highlight the active tool by comparing the current mode to the button's mode and setting color to Theme primary.

**Expected result:** Tapping each toolbar icon switches the drawing mode — pen draws freehand, text places editable text, shapes draw the selected shape.

### 4. Add color picker IconButton and stroke width Slider to the toolbar

Add a Container with a colored circle (current color) as an IconButton. On tap, show a simple grid of 8 color swatches (red, blue, green, black, white, yellow, orange, purple) in a Wrap inside a Show Dialog. On swatch tap, call _controller.setColor(selectedColor) and close the dialog. Below the toolbar Row, add a Slider (min: 1, max: 20, divisions: 19) bound to a local _strokeWidth variable. On changed, call _controller.setStrokeWidth(value).

**Expected result:** Users can pick a drawing color from the swatch grid and adjust stroke thickness with the Slider.

### 5. Implement undo, clear, and export-to-Firebase-Storage buttons

Add three more IconButtons: undo (calls _controller.undo()), delete_outline (calls _controller.clearAll()), and save (calls _controller.exportImage()). The exportImage() method returns a Uint8List of the annotated PNG. On save tap: export → upload the bytes to Firebase Storage at path annotations/{userId}/{timestamp}.png using FirebaseStorage.instance.ref(path).putData(bytes) → get the download URL → pass the URL back to the parent page via an Action Parameter callback.

**Expected result:** Undo removes the last stroke, clear resets the canvas, and save uploads the annotated image to Firebase Storage and returns the download URL.

## Complete code example

File: `ImageEditorWidget Custom Widget`

```dart
// Custom Widget: ImageEditorWidget
// Pubspec: image_painter: ^0.7.0

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_painter/image_painter.dart';
import 'package:firebase_storage/firebase_storage.dart';

class ImageEditorWidget extends StatefulWidget {
  final double width;
  final double height;
  final Uint8List imageBytes;
  final Future Function(String downloadUrl)? onSave;

  const ImageEditorWidget({
    Key? key,
    required this.width,
    required this.height,
    required this.imageBytes,
    this.onSave,
  }) : super(key: key);

  @override
  State<ImageEditorWidget> createState() => _ImageEditorWidgetState();
}

class _ImageEditorWidgetState extends State<ImageEditorWidget> {
  final _controller = ImagePainterController(
    color: Colors.red,
    strokeWidth: 4.0,
    mode: PaintMode.freeStyle,
  );
  double _strokeWidth = 4.0;
  PaintMode _activeMode = PaintMode.freeStyle;

  final _colors = [
    Colors.red, Colors.blue, Colors.green, Colors.black,
    Colors.white, Colors.yellow, Colors.orange, Colors.purple,
  ];

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: widget.width,
      height: widget.height,
      child: Column(children: [
        Expanded(
          child: ImagePainter.memory(
            widget.imageBytes,
            controller: _controller,
            scalable: true,
          ),
        ),
        // Toolbar row
        Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
          _toolBtn(Icons.edit, PaintMode.freeStyle),
          _toolBtn(Icons.text_fields, PaintMode.text),
          _toolBtn(Icons.crop_square, PaintMode.rect),
          _toolBtn(Icons.circle_outlined, PaintMode.circle),
          _toolBtn(Icons.show_chart, PaintMode.line),
          // Color picker
          IconButton(
            icon: Icon(Icons.palette,
                color: _controller.color),
            onPressed: _showColorPicker,
          ),
          IconButton(icon: const Icon(Icons.undo), onPressed: _controller.undo),
          IconButton(icon: const Icon(Icons.delete_outline), onPressed: _controller.clearAll),
          IconButton(icon: const Icon(Icons.save), onPressed: _exportAndUpload),
        ]),
        // Stroke width slider
        Slider(
          value: _strokeWidth,
          min: 1, max: 20, divisions: 19,
          onChanged: (v) {
            setState(() => _strokeWidth = v);
            _controller.setStrokeWidth(v);
          },
        ),
      ]),
    );
  }

  Widget _toolBtn(IconData icon, PaintMode mode) {
    return IconButton(
      icon: Icon(icon,
          color: _activeMode == mode
              ? Theme.of(context).primaryColor
              : Colors.grey),
      onPressed: () {
        setState(() => _activeMode = mode);
        _controller.setMode(mode);
      },
    );
  }

  void _showColorPicker() {
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        content: Wrap(
          spacing: 8,
          children: _colors.map((c) => GestureDetector(
            onTap: () {
              _controller.setColor(c);
              Navigator.pop(context);
            },
            child: CircleAvatar(backgroundColor: c, radius: 18),
          )).toList(),
        ),
      ),
    );
  }

  Future<void> _exportAndUpload() async {
    final bytes = await _controller.exportImage();
    if (bytes == null) return;
    final ref = FirebaseStorage.instance
        .ref('annotations/${DateTime.now().millisecondsSinceEpoch}.png');
    await ref.putData(bytes);
    final url = await ref.getDownloadURL();
    widget.onSave?.call(url);
  }
}
```

## Common mistakes

- **Not providing an undo button on the annotation toolbar** — Users accidentally draw over important parts of the image and have no way to correct it, leading to frustration and starting over. Fix: Always expose _controller.undo() and _controller.clearAll() as IconButtons in the toolbar so users can step back or reset.
- **Forgetting to call controller.dispose() in the widget dispose method** — The ImagePainterController holds references to bitmaps and canvases. Not disposing causes memory leaks, especially when navigating between pages. Fix: Override dispose() in your Custom Widget State class and call _controller.dispose() before super.dispose().
- **Passing a network URL string instead of Uint8List bytes to ImagePainter.memory** — ImagePainter.memory expects raw image bytes. Passing a URL string causes a type error at runtime. Fix: Fetch the image bytes first using http.get(Uri.parse(url)) and pass response.bodyBytes to the widget, or use ImagePainter.network(url) instead.

## Best practices

- Use ImagePainter.memory for images already in memory, ImagePainter.network for remote URLs
- Highlight the active tool icon with the Theme primary color so users know which mode is selected
- Set a sensible default stroke width (3-5 pixels) — too thin is invisible, too thick obscures content
- Show a loading spinner while exportImage() processes and uploads to prevent double-tap saves
- Limit the color palette to 8-12 high-contrast swatches rather than a full color wheel for faster picking
- Wrap the Custom Widget in an AspectRatio to prevent canvas distortion on different screen sizes
- Pass the exported download URL back to the parent via an Action Parameter callback for flexibility

## Frequently asked questions

### Can I load an image from Firebase Storage into the editor?

Yes. Fetch the image bytes using http.get(Uri.parse(storageUrl)), then pass response.bodyBytes as the imageBytes parameter to the Custom Widget.

### Does image_painter support pinch-to-zoom on the canvas?

Yes. Set scalable: true on ImagePainter and users can pinch to zoom in and out while annotating.

### How do I add a text annotation with a custom font size?

Call _controller.setMode(PaintMode.text) and _controller.setStrokeWidth(fontSize). The stroke width parameter doubles as the font size for text mode.

### Can I save the drawing as SVG instead of PNG?

No. image_painter exports only raster PNG via exportImage(). For SVG export, you would need a different package like flutter_drawing_board.

### How do I pre-load annotations so users can resume editing?

image_painter does not support loading previous annotations. Save the final exported PNG each time and let users annotate on top of the previously saved image.

### Can RapidDev help build a production document signing flow?

Yes. RapidDev can implement a full document annotation pipeline with signature capture, multi-page PDF markup, version history, and approval workflows.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-build-a-custom-image-editor-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-build-a-custom-image-editor-in-flutterflow
