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.
Prerequisites
- 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
Create the Custom Widget and define filter matrices
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.
Build the filter preview strip
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.
Display the full-size filtered image preview with adjustment sliders
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.
Capture the filtered image with RepaintBoundary and upload to Firebase Storage
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.
Handle thumbnail performance and loading states
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 working example
1import 'dart:ui' as ui;2import 'dart:typed_data';3import 'package:flutter/material.dart';4import 'package:flutter/rendering.dart';5import 'package:firebase_storage/firebase_storage.dart';67class ImageFilterEditor extends StatefulWidget {8 final String imageUrl;9 final Function(String downloadUrl)? onSaved;10 const ImageFilterEditor({Key? key, required this.imageUrl, this.onSaved}) : super(key: key);11 @override12 State<ImageFilterEditor> createState() => _ImageFilterEditorState();13}1415class _ImageFilterEditorState extends State<ImageFilterEditor> {16 final GlobalKey _repaintKey = GlobalKey();17 int _selectedFilter = 0;18 double _brightness = 0.0;19 double _contrast = 1.0;20 double _saturation = 1.0;21 bool _isSaving = false;2223 static const List<String> _filterNames = [24 'Normal', 'Warm', 'Cool', 'B&W', 'Vintage', 'High Contrast'25 ];2627 static const List<List<double>> _filterMatrices = [28 // Normal (identity)29 [1,0,0,0,0, 0,1,0,0,0, 0,0,1,0,0, 0,0,0,1,0],30 // Warm — boost red/green, reduce blue31 [1.2,0,0,0,10, 0,1.1,0,0,5, 0,0,0.9,0,-10, 0,0,0,1,0],32 // Cool — reduce red, boost blue33 [0.9,0,0,0,-10, 0,1.0,0,0,0, 0,0,1.2,0,15, 0,0,0,1,0],34 // B&W (ITU-R BT.709 luma coefficients)35 [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],36 // Vintage (sepia)37 [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],38 // High Contrast39 [1.5,0,0,0,-40, 0,1.5,0,0,-40, 0,0,1.5,0,-40, 0,0,0,1,0],40 ];4142 List<double> _applyAdjustments(List<double> base) {43 // Brightness: shift translation columns44 final b = _brightness * 50;45 final brightnessMatrix = [1,0,0,0,b, 0,1,0,0,b, 0,0,1,0,b, 0,0,0,1,0];46 // Contrast: scale RGB around midpoint47 final c = _contrast;48 final t = (1 - c) * 128;49 final contrastMatrix = [c,0,0,0,t, 0,c,0,0,t, 0,0,c,0,t, 0,0,0,1,0];50 // Saturation: blend between grayscale and identity51 final s = _saturation;52 final sr = 0.2126 * (1 - s); final sg = 0.7152 * (1 - s); final sb = 0.0722 * (1 - s);53 final satMatrix = [54 sr+s, sg, sb, 0, 0,55 sr, sg+s, sb, 0, 0,56 sr, sg, sb+s, 0, 0,57 0, 0, 0, 1, 0,58 ];59 // Multiply: base * brightness * contrast * saturation60 var result = _multiplyMatrix(base, brightnessMatrix.map((e) => e.toDouble()).toList());61 result = _multiplyMatrix(result, contrastMatrix.map((e) => e.toDouble()).toList());62 result = _multiplyMatrix(result, satMatrix);63 return result;64 }6566 List<double> _multiplyMatrix(List<double> a, List<double> b) {67 // Treat 20-value lists as 4x5 matrices (4 rows, 5 cols)68 // Last implicit row is [0, 0, 0, 0, 1]69 final result = List<double>.filled(20, 0);70 for (int row = 0; row < 4; row++) {71 for (int col = 0; col < 5; col++) {72 double sum = 0;73 for (int k = 0; k < 4; k++) {74 sum += a[row * 5 + k] * b[k * 5 + col];75 }76 if (col == 4) sum += a[row * 5 + 4]; // translation column77 result[row * 5 + col] = sum;78 }79 }80 return result;81 }8283 Future<void> _saveImage() async {84 setState(() => _isSaving = true);85 try {86 await WidgetsBinding.instance.endOfFrame;87 final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;88 final image = await boundary.toImage(pixelRatio: 2.0);89 final byteData = await image.toByteData(format: ui.ImageByteFormat.png);90 final Uint8List pngBytes = byteData!.buffer.asUint8List();91 final ref = FirebaseStorage.instance92 .ref('filtered_images/${DateTime.now().millisecondsSinceEpoch}.png');93 await ref.putData(pngBytes);94 final downloadUrl = await ref.getDownloadURL();95 widget.onSaved?.call(downloadUrl);96 if (mounted) {97 ScaffoldMessenger.of(context).showSnackBar(98 SnackBar(content: Text('Image saved successfully')),99 );100 }101 } catch (e) {102 if (mounted) {103 ScaffoldMessenger.of(context).showSnackBar(104 SnackBar(content: Text('Save failed: $e')),105 );106 }107 } finally {108 if (mounted) setState(() => _isSaving = false);109 }110 }111112 @override113 Widget build(BuildContext context) {114 final matrix = _applyAdjustments(_filterMatrices[_selectedFilter]);115 return Column(116 children: [117 // Full-size filtered preview118 Expanded(119 child: Stack(120 children: [121 RepaintBoundary(122 key: _repaintKey,123 child: ColorFiltered(124 colorFilter: ColorFilter.matrix(matrix),125 child: Image.network(widget.imageUrl, fit: BoxFit.contain,126 width: double.infinity),127 ),128 ),129 if (_isSaving)130 const Center(child: CircularProgressIndicator()),131 ],132 ),133 ),134 const SizedBox(height: 8),135 // Adjustment sliders136 _buildSlider('Brightness', _brightness, -1.0, 1.0,137 (v) => setState(() => _brightness = v)),138 _buildSlider('Contrast', _contrast, 0.5, 2.0,139 (v) => setState(() => _contrast = v)),140 _buildSlider('Saturation', _saturation, 0.0, 2.0,141 (v) => setState(() => _saturation = v)),142 const SizedBox(height: 8),143 // Filter thumbnail strip144 SizedBox(145 height: 110,146 child: ListView.builder(147 scrollDirection: Axis.horizontal,148 itemCount: _filterNames.length,149 padding: const EdgeInsets.symmetric(horizontal: 8),150 itemBuilder: (context, index) {151 final isSelected = index == _selectedFilter;152 return GestureDetector(153 onTap: () => setState(() => _selectedFilter = index),154 child: Padding(155 padding: const EdgeInsets.symmetric(horizontal: 4),156 child: Column(157 children: [158 Container(159 decoration: BoxDecoration(160 border: isSelected161 ? Border.all(color: Colors.blue, width: 2)162 : null,163 borderRadius: BorderRadius.circular(8),164 ),165 child: ClipRRect(166 borderRadius: BorderRadius.circular(8),167 child: ColorFiltered(168 colorFilter: ColorFilter.matrix(169 _filterMatrices[index]),170 child: Image.network(widget.imageUrl,171 width: 80, height: 80, fit: BoxFit.cover,172 cacheWidth: 80, cacheHeight: 80),173 ),174 ),175 ),176 const SizedBox(height: 4),177 Text(_filterNames[index],178 style: TextStyle(179 fontSize: 11,180 fontWeight: isSelected181 ? FontWeight.bold182 : FontWeight.normal)),183 ],184 ),185 ),186 );187 },188 ),189 ),190 // Save button191 Padding(192 padding: const EdgeInsets.all(12),193 child: SizedBox(194 width: double.infinity,195 child: ElevatedButton.icon(196 onPressed: _isSaving ? null : _saveImage,197 icon: const Icon(Icons.save),198 label: Text(_isSaving ? 'Saving...' : 'Save Filtered Image'),199 ),200 ),201 ),202 ],203 );204 }205206 Widget _buildSlider(String label, double value, double min, double max,207 ValueChanged<double> onChanged) {208 return Padding(209 padding: const EdgeInsets.symmetric(horizontal: 16),210 child: Row(211 children: [212 SizedBox(width: 80, child: Text(label, style: const TextStyle(fontSize: 12))),213 Expanded(214 child: Slider(value: value, min: min, max: max, onChanged: onChanged),215 ),216 SizedBox(width: 40, child: Text(value.toStringAsFixed(1),217 style: const TextStyle(fontSize: 12))),218 ],219 ),220 );221 }222}Common mistakes when creating a Custom Image Editor with Filters in FlutterFlow
Why it's a problem: Applying filters to full-resolution images in the thumbnail strip
How to avoid: 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.
Why it's a problem: Using the wrong number of values in ColorFilter.matrix
How to avoid: 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].
Why it's a problem: RepaintBoundary capture returning a blank or transparent image
How to avoid: 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
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Flutter StatefulWidget that displays an image with six Instagram-style color filters (Normal, Warm, Cool, B&W, Vintage, High Contrast) using ColorFilter.matrix(). Include a horizontal thumbnail preview strip, sliders for brightness/contrast/saturation adjustment, and a save function using RepaintBoundary that exports to PNG bytes.
Create a Custom Widget called ImageFilterEditor with a parameter imageUrl (String). The widget should show the image with a color filter applied, a horizontal scrollable row of filter thumbnail previews, and sliders for brightness, contrast, and saturation. Add a Save button that captures the filtered image and uploads it to Firebase Storage.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation