# How to Create a Custom Image Editor with Filters in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Intermediate
- Time required: 25-35 min
- Compatibility: FlutterFlow Pro+ (Custom Widget required)
- Last updated: March 2026

## TL;DR

Create an Instagram-style image filter editor as a FlutterFlow Custom Widget using ColorFiltered with ColorFilter.matrix(). Define six filters (Normal, Warm, Cool, B&W, Vintage, High Contrast) as 20-value color matrices. Show a horizontal scrollable strip of 80x80 thumbnail previews each wrapped in ColorFiltered. Tap a thumbnail to apply that filter to the full-size preview. Adjust brightness, contrast, and saturation with Sliders that multiply additional matrices. Save the result using RepaintBoundary with a GlobalKey to capture the filtered image as PNG bytes, then upload to Firebase Storage.

## Instagram-style color filters with ColorFilter.matrix in a Custom Widget

Flutter's ColorFiltered widget accepts a ColorFilter.matrix() that transforms every pixel using a 4x5 (20-value) color matrix. This tutorial builds a Custom Widget that displays an image, lets users swipe through six preset filters via a thumbnail strip, fine-tune brightness/contrast/saturation with sliders, and save the final result as a PNG file uploaded to Firebase Storage. Everything runs on the GPU via the Skia rendering engine, so filters apply instantly even on large images.

## Before you start

- A FlutterFlow project with Firebase connected (Authentication + Storage enabled)
- FlutterFlow Pro plan or higher for Custom Widget support
- An image URL or local asset to use as the source image
- Basic understanding of FlutterFlow Component State and Custom Widget parameters

## Step-by-step guide

### 1. Create the Custom Widget and define filter matrices

In FlutterFlow, go to Custom Code > Custom Widgets > Add Custom Widget. Name it ImageFilterEditor. Add a parameter imageUrl (String). Inside the widget state, define your filters as a List of maps with a label and a List<double> of exactly 20 values each:

- Normal (identity): [1,0,0,0,0, 0,1,0,0,0, 0,0,1,0,0, 0,0,0,1,0]
- Warm: boost red/green channels: [1.2,0,0,0,10, 0,1.1,0,0,5, 0,0,0.9,0,-10, 0,0,0,1,0]
- Cool: boost blue channel: [0.9,0,0,0,-10, 0,1.0,0,0,0, 0,0,1.2,0,15, 0,0,0,1,0]
- B&W (grayscale): [0.2126,0.7152,0.0722,0,0, 0.2126,0.7152,0.0722,0,0, 0.2126,0.7152,0.0722,0,0, 0,0,0,1,0]
- Vintage (sepia): [0.393,0.769,0.189,0,0, 0.349,0.686,0.168,0,0, 0.272,0.534,0.131,0,0, 0,0,0,1,0]
- High Contrast: [1.5,0,0,0,-40, 0,1.5,0,0,-40, 0,0,1.5,0,-40, 0,0,0,1,0]

Store these in a final list variable. Add a Component State variable selectedFilterIndex (int, default 0) and three doubles: brightness (default 0.0), contrast (default 1.0), saturation (default 1.0).

**Expected result:** A Custom Widget stub with six named filter matrices and state variables for the selected filter, brightness, contrast, and saturation.

### 2. Build the filter preview strip

In the widget's build method, add a SizedBox with height 120 containing a ListView.builder with scrollDirection: Axis.horizontal and itemCount equal to the number of filters. Each item is a GestureDetector wrapping a Column: on top, a ClipRRect (borderRadius 8) containing a ColorFiltered widget with colorFilter: ColorFilter.matrix(filters[index].matrix) wrapping an Image.network(widget.imageUrl, width: 80, height: 80, fit: BoxFit.cover). Below the image, a Text widget showing the filter name (fontSize: 11). Add a border highlight (2px blue) on the selected item by checking if index == selectedFilterIndex. On tap, call setState(() => selectedFilterIndex = index). Use cacheWidth: 80 and cacheHeight: 80 on the Image widget so Flutter decodes thumbnails at 80x80 resolution instead of full size.

**Expected result:** A horizontal scrollable row of six 80x80 thumbnail images, each showing the source photo with a different color filter applied. Tapping a thumbnail highlights it with a blue border.

### 3. Display the full-size filtered image preview with adjustment sliders

Above the filter strip, add the main preview: a RepaintBoundary with a GlobalKey (_repaintKey) wrapping a ColorFiltered widget. The colorFilter combines the selected filter matrix with brightness/contrast/saturation adjustments. Build a helper function List<double> applyAdjustments(List<double> base, double brightness, double contrast, double saturation) that multiplies the base matrix with adjustment matrices. Brightness adds to the translation column: [1,0,0,0,brightness*50, ...]. Contrast scales RGB: [contrast,0,0,0,(1-contrast)*128, ...]. Saturation blends between grayscale and identity. Below the preview, add three Slider widgets labeled Brightness (-1.0 to 1.0), Contrast (0.5 to 2.0), and Saturation (0.0 to 2.0), each calling setState on change to update the corresponding state variable.

**Expected result:** A full-size image preview updates in real-time as users select filters and drag sliders. The RepaintBoundary wraps everything needed for the save step.

### 4. Capture the filtered image with RepaintBoundary and upload to Firebase Storage

Add a Save button below the sliders. On tap, execute an async function: first await WidgetsBinding.instance.endOfFrame to ensure the widget tree is fully laid out. Then get the RenderRepaintBoundary via _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary. Call boundary.toImage(pixelRatio: 2.0) for high-resolution capture. Convert to PNG bytes: final byteData = await image.toByteData(format: ui.ImageByteFormat.png); final Uint8List pngBytes = byteData!.buffer.asUint8List(). Upload to Firebase Storage: final ref = FirebaseStorage.instance.ref('filtered_images/${DateTime.now().millisecondsSinceEpoch}.png'); await ref.putData(pngBytes); final downloadUrl = await ref.getDownloadURL(). Show a loading indicator during upload and a SnackBar with the URL on success. Pass the downloadUrl back to FlutterFlow via an Action Parameter callback.

**Expected result:** Tapping Save captures the filtered image at 2x resolution, uploads it to Firebase Storage, and returns the download URL.

### 5. Handle thumbnail performance and loading states

For the thumbnail strip, always use cacheWidth: 80 and cacheHeight: 80 on Image.network so each preview decodes at thumbnail resolution. The full-size preview uses the original resolution without cache constraints. Add a boolean isSaving state variable. When Save is tapped, set isSaving = true and show a CircularProgressIndicator overlay on the image. On completion or error, set isSaving = false. Wrap the upload in a try-catch and show a SnackBar with the error message on failure. Disable the Save button while isSaving is true to prevent duplicate uploads.

**Expected result:** Filter thumbnails scroll smoothly without jank. The save flow shows a loading spinner and handles errors gracefully.

## Complete code example

File: `image_filter_editor.dart`

```dart
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:firebase_storage/firebase_storage.dart';

class ImageFilterEditor extends StatefulWidget {
  final String imageUrl;
  final Function(String downloadUrl)? onSaved;
  const ImageFilterEditor({Key? key, required this.imageUrl, this.onSaved}) : super(key: key);
  @override
  State<ImageFilterEditor> createState() => _ImageFilterEditorState();
}

class _ImageFilterEditorState extends State<ImageFilterEditor> {
  final GlobalKey _repaintKey = GlobalKey();
  int _selectedFilter = 0;
  double _brightness = 0.0;
  double _contrast = 1.0;
  double _saturation = 1.0;
  bool _isSaving = false;

  static const List<String> _filterNames = [
    'Normal', 'Warm', 'Cool', 'B&W', 'Vintage', 'High Contrast'
  ];

  static const List<List<double>> _filterMatrices = [
    // Normal (identity)
    [1,0,0,0,0, 0,1,0,0,0, 0,0,1,0,0, 0,0,0,1,0],
    // Warm — boost red/green, reduce blue
    [1.2,0,0,0,10, 0,1.1,0,0,5, 0,0,0.9,0,-10, 0,0,0,1,0],
    // Cool — reduce red, boost blue
    [0.9,0,0,0,-10, 0,1.0,0,0,0, 0,0,1.2,0,15, 0,0,0,1,0],
    // B&W (ITU-R BT.709 luma coefficients)
    [0.2126,0.7152,0.0722,0,0, 0.2126,0.7152,0.0722,0,0, 0.2126,0.7152,0.0722,0,0, 0,0,0,1,0],
    // Vintage (sepia)
    [0.393,0.769,0.189,0,0, 0.349,0.686,0.168,0,0, 0.272,0.534,0.131,0,0, 0,0,0,1,0],
    // High Contrast
    [1.5,0,0,0,-40, 0,1.5,0,0,-40, 0,0,1.5,0,-40, 0,0,0,1,0],
  ];

  List<double> _applyAdjustments(List<double> base) {
    // Brightness: shift translation columns
    final b = _brightness * 50;
    final brightnessMatrix = [1,0,0,0,b, 0,1,0,0,b, 0,0,1,0,b, 0,0,0,1,0];
    // Contrast: scale RGB around midpoint
    final c = _contrast;
    final t = (1 - c) * 128;
    final contrastMatrix = [c,0,0,0,t, 0,c,0,0,t, 0,0,c,0,t, 0,0,0,1,0];
    // Saturation: blend between grayscale and identity
    final s = _saturation;
    final sr = 0.2126 * (1 - s); final sg = 0.7152 * (1 - s); final sb = 0.0722 * (1 - s);
    final satMatrix = [
      sr+s, sg,   sb,   0, 0,
      sr,   sg+s, sb,   0, 0,
      sr,   sg,   sb+s, 0, 0,
      0,    0,    0,    1, 0,
    ];
    // Multiply: base * brightness * contrast * saturation
    var result = _multiplyMatrix(base, brightnessMatrix.map((e) => e.toDouble()).toList());
    result = _multiplyMatrix(result, contrastMatrix.map((e) => e.toDouble()).toList());
    result = _multiplyMatrix(result, satMatrix);
    return result;
  }

  List<double> _multiplyMatrix(List<double> a, List<double> b) {
    // Treat 20-value lists as 4x5 matrices (4 rows, 5 cols)
    // Last implicit row is [0, 0, 0, 0, 1]
    final result = List<double>.filled(20, 0);
    for (int row = 0; row < 4; row++) {
      for (int col = 0; col < 5; col++) {
        double sum = 0;
        for (int k = 0; k < 4; k++) {
          sum += a[row * 5 + k] * b[k * 5 + col];
        }
        if (col == 4) sum += a[row * 5 + 4]; // translation column
        result[row * 5 + col] = sum;
      }
    }
    return result;
  }

  Future<void> _saveImage() async {
    setState(() => _isSaving = true);
    try {
      await WidgetsBinding.instance.endOfFrame;
      final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
      final image = await boundary.toImage(pixelRatio: 2.0);
      final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
      final Uint8List pngBytes = byteData!.buffer.asUint8List();
      final ref = FirebaseStorage.instance
          .ref('filtered_images/${DateTime.now().millisecondsSinceEpoch}.png');
      await ref.putData(pngBytes);
      final downloadUrl = await ref.getDownloadURL();
      widget.onSaved?.call(downloadUrl);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Image saved successfully')),
        );
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Save failed: $e')),
        );
      }
    } finally {
      if (mounted) setState(() => _isSaving = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final matrix = _applyAdjustments(_filterMatrices[_selectedFilter]);
    return Column(
      children: [
        // Full-size filtered preview
        Expanded(
          child: Stack(
            children: [
              RepaintBoundary(
                key: _repaintKey,
                child: ColorFiltered(
                  colorFilter: ColorFilter.matrix(matrix),
                  child: Image.network(widget.imageUrl, fit: BoxFit.contain,
                      width: double.infinity),
                ),
              ),
              if (_isSaving)
                const Center(child: CircularProgressIndicator()),
            ],
          ),
        ),
        const SizedBox(height: 8),
        // Adjustment sliders
        _buildSlider('Brightness', _brightness, -1.0, 1.0,
            (v) => setState(() => _brightness = v)),
        _buildSlider('Contrast', _contrast, 0.5, 2.0,
            (v) => setState(() => _contrast = v)),
        _buildSlider('Saturation', _saturation, 0.0, 2.0,
            (v) => setState(() => _saturation = v)),
        const SizedBox(height: 8),
        // Filter thumbnail strip
        SizedBox(
          height: 110,
          child: ListView.builder(
            scrollDirection: Axis.horizontal,
            itemCount: _filterNames.length,
            padding: const EdgeInsets.symmetric(horizontal: 8),
            itemBuilder: (context, index) {
              final isSelected = index == _selectedFilter;
              return GestureDetector(
                onTap: () => setState(() => _selectedFilter = index),
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 4),
                  child: Column(
                    children: [
                      Container(
                        decoration: BoxDecoration(
                          border: isSelected
                              ? Border.all(color: Colors.blue, width: 2)
                              : null,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(8),
                          child: ColorFiltered(
                            colorFilter: ColorFilter.matrix(
                                _filterMatrices[index]),
                            child: Image.network(widget.imageUrl,
                                width: 80, height: 80, fit: BoxFit.cover,
                                cacheWidth: 80, cacheHeight: 80),
                          ),
                        ),
                      ),
                      const SizedBox(height: 4),
                      Text(_filterNames[index],
                          style: TextStyle(
                              fontSize: 11,
                              fontWeight: isSelected
                                  ? FontWeight.bold
                                  : FontWeight.normal)),
                    ],
                  ),
                ),
              );
            },
          ),
        ),
        // Save button
        Padding(
          padding: const EdgeInsets.all(12),
          child: SizedBox(
            width: double.infinity,
            child: ElevatedButton.icon(
              onPressed: _isSaving ? null : _saveImage,
              icon: const Icon(Icons.save),
              label: Text(_isSaving ? 'Saving...' : 'Save Filtered Image'),
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildSlider(String label, double value, double min, double max,
      ValueChanged<double> onChanged) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Row(
        children: [
          SizedBox(width: 80, child: Text(label, style: const TextStyle(fontSize: 12))),
          Expanded(
            child: Slider(value: value, min: min, max: max, onChanged: onChanged),
          ),
          SizedBox(width: 40, child: Text(value.toStringAsFixed(1),
              style: const TextStyle(fontSize: 12))),
        ],
      ),
    );
  }
}
```

## Common mistakes

- **Applying filters to full-resolution images in the thumbnail strip** — Without cacheWidth/cacheHeight, Flutter decodes the original image at full resolution for every thumbnail. With six filters, that means 6+ full-resolution copies decoded and rendered simultaneously, causing frame drops and high memory usage on mobile devices. Fix: Set cacheWidth: 80 and cacheHeight: 80 on the Image.network widget inside each thumbnail. This tells the image decoder to resize at decode time, so only 80x80 pixel bitmaps are held in memory for the preview strip.
- **Using the wrong number of values in ColorFilter.matrix** — ColorFilter.matrix() requires exactly 20 values as a flat List<double>. The matrix is 4 rows by 5 columns: each row handles one output channel (R, G, B, A) with 4 input multipliers plus 1 translation value. Passing 16 values (a 4x4 matrix) or 25 values causes a runtime assertion error. Fix: Always verify your matrix has exactly 20 values. The layout is: [rR, rG, rB, rA, rTranslate, gR, gG, gB, gA, gTranslate, bR, bG, bB, bA, bTranslate, aR, aG, aB, aA, aTranslate]. The identity matrix is [1,0,0,0,0, 0,1,0,0,0, 0,0,1,0,0, 0,0,0,1,0].
- **RepaintBoundary capture returning a blank or transparent image** — If you call toImage() immediately after setState or before the widget tree has been laid out for the current frame, the RenderRepaintBoundary may not have painted yet. This returns a blank (transparent) image. Fix: Await WidgetsBinding.instance.endOfFrame before calling boundary.toImage(). This ensures the current frame has completed layout and painting, so the captured image contains the fully rendered filtered result.

## Best practices

- Use cacheWidth and cacheHeight on Image.network for thumbnails to decode at thumbnail resolution and save GPU memory
- Keep filter matrices as static const lists since they never change at runtime
- Apply brightness/contrast/saturation via matrix multiplication rather than stacking multiple ColorFiltered widgets, which creates extra compositing layers
- Set pixelRatio: 2.0 in toImage() for sharp exports on high-DPI screens without excessive file size
- Wrap the save flow in try-catch with user-facing error messages — Firebase Storage upload can fail due to network or auth issues
- Disable the Save button during upload to prevent duplicate uploads from impatient taps
- Test filter performance with a large source image (3000x4000) on a mid-range Android device to catch memory issues before release

## Frequently asked questions

### Can I create my own custom filter beyond the six presets?

Yes. Define a new 20-value List<double> representing your desired color transformation. Each value controls how input RGBA channels map to output channels. Experiment by modifying the identity matrix one value at a time — for example, setting index 0 to 1.3 boosts red output from red input by 30%. Add your new list and label to the _filterMatrices and _filterNames arrays.

### Why does ColorFilter.matrix require exactly 20 values?

The matrix is 4 rows by 5 columns. Each row computes one output channel (Red, Green, Blue, Alpha). The 5 columns are: multiply by input R, multiply by input G, multiply by input B, multiply by input A, and add a translation constant. So 4 channels times 5 values equals 20. The translation column is what lets you brighten or shift colors without depending on input values.

### Does ColorFiltered work on FlutterFlow web builds?

Yes. ColorFiltered is a core Flutter widget that works on iOS, Android, and web. On web it uses the CanvasKit renderer (default in FlutterFlow) which supports the full Skia color matrix pipeline. No platform-specific packages are needed.

### How do I let users pick an image from their gallery before filtering?

Add an image_picker dependency or use FlutterFlow's built-in Upload Photo action to let users select an image. Store the file path or download URL in a Component State variable, then pass it as the imageUrl parameter to the ImageFilterEditor widget. The filter editor works with any image URL or file path.

### Can I chain multiple filters together instead of picking just one?

Yes. Multiply two filter matrices together using the 4x5 matrix multiplication function shown in the complete code. For example, to combine Warm and High Contrast, call _multiplyMatrix(warmMatrix, highContrastMatrix). The result is a single 20-value matrix that applies both effects in one GPU pass.

### Why does the saved image look different from the on-screen preview?

This usually happens when the pixelRatio in toImage() differs from the device pixel ratio, causing slight rounding differences. Use pixelRatio: 2.0 for consistent results. Also ensure the RepaintBoundary wraps only the ColorFiltered image — not the sliders or filter strip — otherwise those UI elements get captured too.

### Can RapidDev help build a production image editing feature?

Yes. A production-grade image editor with custom filter creation, filter intensity sliders, crop/rotate tools, layer compositing, and high-resolution export involves Custom Widget code and Firebase Storage architecture beyond the visual builder. RapidDev can build and optimize the full pipeline.

---

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