Flutter Advanced Canvas Editor
A Flutter package for creating, editing, and exporting canvas-based artwork with advanced features
Preview
Features
- Photoshop-style layer system with multiple named layers
- Create, delete, duplicate, rename, and reorder layers
- Per-layer drawing points and components
- Layer visibility toggle, opacity control (0-100%), and lock functionality
- Merge layers and clear individual layers
- Draw, rotate, and delete components on a canvas
- Global undo and redo actions that snapshot all layers together
- Export the canvas as a PNG image
- Callback integration for canvas exports and user actions
Installation
Add the following line to your pubspec.yaml file:
dependencies:
flutter_advanced_canvas_editor:
Usage
import 'package:flutter/material.dart';
import 'package:flutter_advanced_canvas_editor/flutter_advanced_canvas_editor.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late CanvasController controller;
bool isDrawing = false;
bool isErasing = false;
bool _layerPanelExpanded = false;
@override
void initState() {
super.initState();
controller = CanvasController(
(pngBytes) {
print('PNG bytes exporting canvas: $pngBytes');
},
onUndo: () {
print('Undo action triggered - validation flags reset');
},
onRedo: () {
print('Redo action triggered - validation flags reset');
},
onErase: () {
print('Erase mode enabled - validation flags reset');
},
);
// Set state callback once in initState
controller.setOnStateChanged((isDrawing, isErasing) {
if (mounted) {
setState(() {
this.isDrawing = isDrawing;
this.isErasing = isErasing;
});
}
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: SafeArea(
child: Column(
children: [
// Layer navbar at top (collapsible)
LayerNavBar(
controller: controller,
isExpanded: _layerPanelExpanded,
onToggle: () {
setState(() {
_layerPanelExpanded = !_layerPanelExpanded;
});
},
),
// Main content
Expanded(
child: Column(
children: [
Expanded(
child: Center(
child: CanvasWidget(
controller: controller,
backgroundImage: 'assets/images/background.png',
iconsSize: 25.0,
),
),
),
CustomDraggableItems(controller: controller, items: [
Image.asset('assets/images/vehicle.png', width: 50, height: 50),
Image.asset('assets/images/vehicle.png', width: 50, height: 50),
// Add more items here
]),
CustomActionButtons(controller: controller, buttons: [
IconButton(icon: Icon(Icons.undo), onPressed: controller.undo),
IconButton(icon: Icon(Icons.redo), onPressed: controller.redo),
IconButton(icon: Icon(Icons.delete), onPressed: controller.clearAll),
IconButton(
icon: Icon(Icons.edit),
color: !controller.isDrawing ? Colors.black : Colors.red,
onPressed: !controller.isDrawing
? controller.enableDrawing
: controller.disableDrawingErasing,
),
IconButton(
icon: Icon(Icons.brush),
color: !controller.isErasing ? Colors.black : Colors.red,
onPressed: !controller.isErasing
? controller.enableErasing
: controller.disableDrawingErasing,
),
IconButton(icon: Icon(Icons.save), onPressed: controller.exportCanvas),
]),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Drawing: $isDrawing, Erasing: $isErasing'),
),
],
),
),
],
),
),
),
);
}
}
class CustomDraggableItems extends StatelessWidget {
final CanvasController controller;
final List<Widget> items;
CustomDraggableItems({required this.controller, required this.items});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (onPanStart) {
controller.disableDrawingErasing();
},
onPanEnd: (onPanEnd) {
controller.disableDrawingErasing();
},
child: Container(
height: 100,
child: ListView(
scrollDirection: Axis.horizontal,
children: items.map((item) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Draggable<Widget>(
data: item,
feedback: item,
childWhenDragging: Opacity(
opacity: 0.5,
child: item,
),
child: item,
onDragStarted: () {
controller.disableDrawingErasing();
},
onDragEnd: (details) {
controller.disableDrawingErasing();
},
onDraggableCanceled: (velocity, offset) {
controller.disableDrawingErasing();
},
),
);
}).toList(),
),
),
);
}
}
class CustomActionButtons extends StatelessWidget {
final CanvasController controller;
final List<Widget> buttons;
CustomActionButtons({required this.controller, required this.buttons});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: buttons,
);
}
}
// Layer Navigation Bar Widget
class LayerNavBar extends StatelessWidget {
final CanvasController controller;
final bool isExpanded;
final VoidCallback onToggle;
const LayerNavBar({
Key? key,
required this.controller,
required this.isExpanded,
required this.onToggle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: Colors.grey[300],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header bar (always visible)
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
IconButton(
icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
onPressed: onToggle,
tooltip: isExpanded ? 'Collapse Layers' : 'Expand Layers',
),
Text('Layers (${controller.layers.length})',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
SizedBox(width: 16),
IconButton(
icon: Icon(Icons.add, size: 20),
onPressed: controller.createLayer,
tooltip: 'Add Layer',
),
SizedBox(width: 8),
// Show current layer info in collapsed state
if (!isExpanded)
Expanded(
child: Text(
'Active: ${controller.currentLayer?.name ?? "None"}',
style: TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// Expandable layer list
if (isExpanded)
Container(
key: ValueKey('layer_list_${controller.layers.length}'),
height: 200,
color: Colors.grey[200],
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.all(8),
itemCount: controller.layers.length,
itemBuilder: (context, index) {
// Reverse index for top-to-bottom display
final reverseIndex = controller.layers.length - 1 - index;
final layer = controller.layers[reverseIndex];
final isActive = controller.currentLayerIndex == reverseIndex;
return LayerCard(
key: ValueKey(layer.id),
layer: layer,
layerIndex: reverseIndex,
isActive: isActive,
controller: controller,
onTap: () {
controller.setCurrentLayer(reverseIndex);
},
);
},
),
),
],
),
);
}
}
class LayerCard extends StatefulWidget {
final CanvasLayer layer;
final int layerIndex;
final bool isActive;
final CanvasController controller;
final VoidCallback onTap;
const LayerCard({
Key? key,
required this.layer,
required this.layerIndex,
required this.isActive,
required this.controller,
required this.onTap,
}) : super(key: key);
@override
_LayerCardState createState() => _LayerCardState();
}
class _LayerCardState extends State<LayerCard> {
bool _isRenaming = false;
late TextEditingController _nameController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.layer.name);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
child: Container(
width: 180,
margin: EdgeInsets.only(right: 8),
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: widget.isActive ? Colors.blue[100] : Colors.white,
border: Border.all(
color: widget.isActive ? Colors.blue : Colors.grey[300]!,
width: widget.isActive ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Layer name (editable)
_isRenaming
? TextField(
controller: _nameController,
autofocus: true,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 4),
),
onSubmitted: (value) {
widget.controller.renameLayer(widget.layerIndex, value);
setState(() {
_isRenaming = false;
});
},
)
: GestureDetector(
onDoubleTap: () {
setState(() {
_isRenaming = true;
});
},
child: Text(
widget.layer.name,
style: TextStyle(
fontSize: 14,
fontWeight: widget.isActive ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
SizedBox(height: 8),
// Controls row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Visibility toggle
IconButton(
icon: Icon(
widget.layer.visible ? Icons.visibility : Icons.visibility_off,
size: 20,
),
onPressed: () {
widget.controller.setLayerVisibility(
widget.layerIndex,
!widget.layer.visible,
);
},
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
tooltip: widget.layer.visible ? 'Hide' : 'Show',
),
// Lock toggle
IconButton(
icon: Icon(
widget.layer.locked ? Icons.lock : Icons.lock_open,
size: 20,
),
onPressed: () {
widget.controller.setLayerLocked(
widget.layerIndex,
!widget.layer.locked,
);
},
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
tooltip: widget.layer.locked ? 'Unlock' : 'Lock',
),
// More options menu
PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 20),
padding: EdgeInsets.zero,
onSelected: (value) {
switch (value) {
case 'duplicate':
widget.controller.duplicateLayer(widget.layerIndex);
break;
case 'merge_down':
widget.controller.mergeLayerDown(widget.layerIndex);
break;
case 'clear':
widget.controller.clearLayer(widget.layerIndex);
break;
case 'delete':
widget.controller.deleteLayer(widget.layerIndex);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'duplicate',
child: Text('Duplicate', style: TextStyle(fontSize: 12))),
PopupMenuItem(
value: 'merge_down',
child: Text('Merge Down', style: TextStyle(fontSize: 12)),
enabled: widget.layerIndex > 0,
),
PopupMenuItem(
value: 'clear',
child: Text('Clear', style: TextStyle(fontSize: 12))),
PopupMenuItem(
value: 'delete',
child: Text('Delete', style: TextStyle(fontSize: 12)),
enabled: widget.controller.layers.length > 1,
),
],
),
],
),
SizedBox(height: 8),
// Opacity slider
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Opacity: ${(widget.layer.opacity * 100).round()}%',
style: TextStyle(fontSize: 11)),
Slider(
value: widget.layer.opacity,
min: 0.0,
max: 1.0,
divisions: 20,
onChanged: (value) {
widget.controller.setLayerOpacity(widget.layerIndex, value);
},
),
],
),
],
),
),
);
}
}
Save Canvas as PNG Bytes
late CanvasController controller;
@override
void initState() {
super.initState();
controller = CanvasController((pngBytes) {
print('PNG bytes exporting canvas: $pngBytes');
});
}
Contributing
Contributions are welcome! Feel free to open issues and pull requests to suggest new features, report bugs, or improve the codebase.
License
This project is licensed under the MIT License - see the LICENSE file for details.