Build a photo editor in FlutterFlow using image_cropper for crop and rotate, ColorFiltered widget for brightness, contrast, and saturation adjustments, and a draggable positioned Text widget for text overlays. Apply filters to a downscaled preview image during editing to keep the UI responsive, then process the full resolution only when the user saves. Upload the final edited image to Firebase Storage.
A Real Photo Editor Inside Your App
FlutterFlow does not include photo editing widgets out of the box, but the Flutter package ecosystem provides everything you need. This guide assembles image_cropper for geometry adjustments, ColorFiltered and ColorMatrix for color grading, and a Positioned + Draggable widget combination for text overlays. The key to a fast experience is always working on a downscaled preview during interactive editing, only processing the full resolution image when the user confirms and saves.
Prerequisites
- FlutterFlow Pro plan (Custom Widgets required)
- Firebase Storage configured in your project
- An image picker already implemented (MediaQuery or File Upload action)
- Basic understanding of FlutterFlow Custom Widgets and Custom Actions
Step-by-step guide
Add the image_cropper Package and Build the Crop Widget
Add the image_cropper Package and Build the Crop Widget
In FlutterFlow Custom Code > Custom Actions, add image_cropper to your pubspec dependencies. Create a Custom Action called openImageCropper that takes the image path or bytes, opens the native crop UI, and returns the cropped image as bytes. The image_cropper package uses the device's native crop interface on both iOS and Android, which means users get a familiar cropping experience with pinch-to-zoom, rotation handles, and aspect ratio presets like 1:1 (square), 4:3, and 16:9. Configure CropAspectRatioPreset options in the CropStyle parameter to match your app's use case.
1import 'package:image_cropper/image_cropper.dart';2import 'package:flutter/material.dart';34Future<String?> openImageCropper(5 String imagePath,6 BuildContext context,7) async {8 final CroppedFile? croppedFile = await ImageCropper().cropImage(9 sourcePath: imagePath,10 uiSettings: [11 AndroidUiSettings(12 toolbarTitle: 'Crop Photo',13 toolbarColor: Theme.of(context).primaryColor,14 toolbarWidgetColor: Colors.white,15 initAspectRatio: CropAspectRatioPreset.original,16 lockAspectRatio: false,17 aspectRatioPresets: [18 CropAspectRatioPreset.original,19 CropAspectRatioPreset.square,20 CropAspectRatioPreset.ratio4x3,21 CropAspectRatioPreset.ratio16x9,22 ],23 ),24 IOSUiSettings(25 title: 'Crop Photo',26 aspectRatioPresets: [27 CropAspectRatioPreset.original,28 CropAspectRatioPreset.square,29 CropAspectRatioPreset.ratio4x3,30 ],31 ),32 ],33 );34 return croppedFile?.path;35}Expected result: Tapping the Crop button opens the native crop interface. After confirming, the image is replaced with the cropped version in your preview widget.
Build the Filter Preview Grid with ColorFiltered
Build the Filter Preview Grid with ColorFiltered
Create a Custom Widget called FilterGrid that displays a horizontal scrollable row of filter thumbnails. Each thumbnail wraps a small Image widget inside a ColorFiltered widget with a different ColorMatrix. Define 6-8 filter presets as static constants: Original, Vivid, Cool, Warm, Fade, Noir, and so on. When the user taps a thumbnail, update a Page State variable called activeFilterMatrix with that filter's ColorMatrix values. Apply the same ColorFiltered widget to the main preview image using the activeFilterMatrix state. This way the filter preview and the main image always use the same matrix without duplicating logic.
1import 'dart:typed_data';2import 'package:flutter/widgets.dart';34class PhotoFilters {5 static const List<double> original = [6 1, 0, 0, 0, 0,7 0, 1, 0, 0, 0,8 0, 0, 1, 0, 0,9 0, 0, 0, 1, 0,10 ];1112 static const List<double> vivid = [13 1.3, 0, 0, 0, -15,14 0, 1.2, 0, 0, -10,15 0, 0, 1.1, 0, -5,16 0, 0, 0, 1, 0,17 ];1819 static const List<double> cool = [20 0.9, 0, 0, 0, 0,21 0, 1.0, 0, 0, 0,22 0, 0, 1.3, 0, 10,23 0, 0, 0, 1, 0,24 ];2526 static const List<double> warm = [27 1.2, 0, 0, 0, 15,28 0, 1.0, 0, 0, 5,29 0, 0, 0.8, 0, -5,30 0, 0, 0, 1, 0,31 ];3233 static const List<double> noir = [34 0.33, 0.33, 0.33, 0, 0,35 0.33, 0.33, 0.33, 0, 0,36 0.33, 0.33, 0.33, 0, 0,37 0, 0, 0, 1, 0,38 ];3940 static const List<double> fade = [41 0.9, 0, 0, 0, 30,42 0, 0.9, 0, 0, 30,43 0, 0, 0.9, 0, 30,44 0, 0, 0, 1, 0,45 ];46}Expected result: A horizontal row of filter thumbnails appears below the main image. Tapping each updates the main image preview instantly.
Add Brightness, Contrast, and Saturation Sliders
Add Brightness, Contrast, and Saturation Sliders
Add three Slider widgets below the filter grid with labels Brightness, Contrast, and Saturation. Each slider maps to a Page State variable (brightnessValue, contrastValue, saturationValue) ranging from -1.0 to 1.0 with 0.0 as the neutral center. Create a Custom Function called buildAdjustmentMatrix that combines the three adjustment values into a single ColorMatrix. Apply this combined matrix as a second nested ColorFiltered widget around the first filter ColorFiltered widget. This lets the user pick a filter style and then fine-tune it with the sliders independently.
Expected result: Moving the Brightness slider lightens or darkens the photo in real time while preserving the selected filter's color style.
Implement a Draggable Text Overlay
Implement a Draggable Text Overlay
Allow users to add custom text on top of the photo. Add a TextField for text input and a color picker row for text color. When the user taps 'Add Text', create a Page State variable textOverlayContent (String) and textOverlayPosition (Offset, defaulting to center). Wrap a Text widget in a Positioned widget inside a Stack with the photo. Add a GestureDetector with onPanUpdate to the text widget that updates the Offset in Page State on every drag event. Add a text size slider (textSize, 12.0 to 72.0). The text stays at the dragged position and moves smoothly with the finger.
Expected result: Typing text and tapping Add Text places a moveable text label on the photo that the user can drag to any position.
Capture and Save the Final Edited Image
Capture and Save the Final Edited Image
To save the composite image (photo + filter + text overlay), wrap the entire photo Stack in a RepaintBoundary widget with a GlobalKey. Create a Custom Action called captureAndSavePhoto that uses RenderRepaintBoundary.toImage() to rasterize the visible widget at a specified pixel ratio (2.0x for high quality). Convert the result to PNG bytes, upload to Firebase Storage in the user's photos folder, and update the user's Firestore document with the new image URL. Show a CircularProgressIndicator during the save operation and a SnackBar confirmation when complete.
1import 'dart:ui' as ui;2import 'package:flutter/rendering.dart';3import 'package:firebase_storage/firebase_storage.dart';4import 'package:cloud_firestore/cloud_firestore.dart';56Future<String?> captureAndSavePhoto(7 GlobalKey repaintKey,8 String userId,9 String filename,10) async {11 final boundary = repaintKey.currentContext?.findRenderObject()12 as RenderRepaintBoundary?;13 if (boundary == null) return null;1415 final ui.Image image = await boundary.toImage(pixelRatio: 2.0);16 final byteData = await image.toByteData(format: ui.ImageByteFormat.png);17 final bytes = byteData!.buffer.asUint8List();1819 final ref = FirebaseStorage.instance20 .ref('users/$userId/edited_photos/$filename.png');21 await ref.putData(bytes, SettableMetadata(contentType: 'image/png'));2223 final downloadUrl = await ref.getDownloadURL();2425 await FirebaseFirestore.instance26 .collection('users')27 .doc(userId)28 .collection('photos')29 .add({30 'url': downloadUrl,31 'created_at': FieldValue.serverTimestamp(),32 'filename': filename,33 });3435 return downloadUrl;36}Expected result: Tapping Save shows a loading indicator, then a confirmation. The edited image appears in Firebase Storage and the Firestore photos subcollection.
Complete working example
1// PhotoEditor filter definitions and adjustment matrix builder2// Use these in your FlutterFlow Custom Widget34class PhotoFilters {5 static const Map<String, List<double>> presets = {6 'Original': [7 1, 0, 0, 0, 0,8 0, 1, 0, 0, 0,9 0, 0, 1, 0, 0,10 0, 0, 0, 1, 0,11 ],12 'Vivid': [13 1.3, 0, 0, 0, -15,14 0, 1.2, 0, 0, -10,15 0, 0, 1.1, 0, -5,16 0, 0, 0, 1, 0,17 ],18 'Cool': [19 0.9, 0, 0, 0, 0,20 0, 1.0, 0, 0, 0,21 0, 0, 1.3, 0, 10,22 0, 0, 0, 1, 0,23 ],24 'Warm': [25 1.2, 0, 0, 0, 15,26 0, 1.0, 0, 0, 5,27 0, 0, 0.8, 0, -5,28 0, 0, 0, 1, 0,29 ],30 'Noir': [31 0.33, 0.33, 0.33, 0, 0,32 0.33, 0.33, 0.33, 0, 0,33 0.33, 0.33, 0.33, 0, 0,34 0, 0, 0, 1, 0,35 ],36 'Fade': [37 0.9, 0, 0, 0, 30,38 0, 0.9, 0, 0, 30,39 0, 0, 0.9, 0, 30,40 0, 0, 0, 1, 0,41 ],42 };43}4445// Builds a combined brightness/contrast/saturation matrix46List<double> buildAdjustmentMatrix(47 double brightness, // -1.0 to 1.048 double contrast, // -1.0 to 1.049 double saturation, // -1.0 to 1.050) {51 final double b = brightness * 255;52 final double c = contrast + 1.0; // 0.0 to 2.053 final double t = (1.0 - c) / 2.0 * 255;5455 // Saturation matrix56 final double sr = (1 - saturation) * 0.3086;57 final double sg = (1 - saturation) * 0.6094;58 final double sb = (1 - saturation) * 0.0820;5960 return [61 c * (sr + saturation), c * sg, c * sb, 0, t + b,62 c * sr, c * (sg + saturation), c * sb, 0, t + b,63 c * sr, c * sg, c * (sb + saturation), 0, t + b,64 0, 0, 0, 1, 0,65 ];66}Common mistakes when creating a Photo Editing Tool in FlutterFlow
Why it's a problem: Applying filters to the full-resolution image during live slider preview
How to avoid: Work on a downscaled version (800x800px max) during interactive preview. Only apply the final settings to the full-resolution image when the user confirms and saves.
Why it's a problem: Trying to save the edited image by converting the original file bytes instead of capturing the widget
How to avoid: Use RepaintBoundary with a GlobalKey to capture the rendered widget as an image. This captures exactly what the user sees: filters, overlays, and all adjustments.
Why it's a problem: Storing the image as a base64 string in Firestore
How to avoid: Always upload images to Firebase Storage and store only the download URL in Firestore.
Best practices
- Always keep a reference to the original unedited image so users can reset to the original after making changes.
- Limit filter previews to 150x150px thumbnails — never render the full image multiple times for the filter strip.
- Use RepaintBoundary to isolate the photo editor canvas from the rest of the UI so Flutter only repaints the photo area during adjustments.
- Add an undo stack (a List of past state snapshots in Page State) so users can step back through edits one at a time.
- Compress the final image before uploading to Firebase Storage — a 90% quality JPEG is typically 5-10x smaller than PNG with barely visible quality difference for photos.
- Show a preview of the saved result before dismissing the editor so users can confirm the exported image looks correct.
- Enforce a maximum output resolution (e.g., 2048x2048px) to control Firebase Storage costs and downstream load times.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a photo editor in a FlutterFlow app. Show me how to use image_cropper for crop and rotate, apply ColorMatrix filters using ColorFiltered widget, build brightness/contrast/saturation sliders that update a combined ColorMatrix in real time, add a draggable text overlay using Stack and GestureDetector, and capture the final result using RepaintBoundary. Include all Dart code.
In my FlutterFlow app, create a Custom Widget called PhotoEditorCanvas that shows a selected image inside a Stack with a RepaintBoundary. Apply a ColorFiltered widget using a Page State variable called activeColorMatrix. Add a Positioned + GestureDetector child for a draggable text overlay controlled by textContent and textPosition Page State variables.
Frequently asked questions
Can I add drawing or brush tools to the photo editor?
Yes, but it requires a Custom Widget using Flutter's CustomPainter. You capture touch points in a List of Offset values stored in Page State, then draw them as a Path on a Canvas layered over the photo in the Stack. This is more complex than filter or text overlays but follows the same Stack-based architecture.
Why does my edited image look pixelated when saved?
This happens when you capture the RepaintBoundary at 1x pixel ratio on a high-DPI device. Increase the pixelRatio parameter in boundary.toImage(pixelRatio: 2.0) to 3.0 on devices with high screen density. Check the device pixel ratio with MediaQuery.of(context).devicePixelRatio and use that value.
Does image_cropper work on web builds of FlutterFlow apps?
No. image_cropper uses native iOS and Android crop interfaces and is not supported on Flutter Web. For web, you would need a pure-Dart solution using CustomPainter with touch handles. If you need web support, consider restricting the crop feature to mobile only with a platform check.
How do I let users save edited images to their camera roll?
Use the image_gallery_saver package in a Custom Action. After capturing the image bytes with RepaintBoundary, pass the Uint8List to ImageGallerySaver.saveImage(). On iOS, you need the NSPhotoLibraryAddUsageDescription permission in Info.plist. On Android, request WRITE_EXTERNAL_STORAGE permission on Android 9 and below.
Can I apply filters and then crop, or does the order matter?
For this implementation, the RepaintBoundary captures the entire visible widget including both filters and text overlays, so the order does not matter for the final output. However, if you run image_cropper first and then apply filters, you benefit from working on a smaller image. Run crop first, then filter, for the most efficient workflow.
How large a photo can this editor handle without performance issues?
ColorFiltered is a GPU operation and handles large images efficiently during display. The performance bottleneck is RepaintBoundary.toImage() at save time on CPU. Images up to 10MP save in 1-3 seconds on modern devices. For very large images, consider running the final processing in a compute isolate to avoid freezing the UI during save.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation