Creating such an editor can be very complicated. The principle is easy, though. Create a notifier that holds your models, observe that notifier and draw the objects accordingly to the models, then modify the model based on the UI interaction.
Here's a model:
class Box {
Box({
required this.center,
required this.size,
required this.rotation,
required this.color,
});
Offset center;
Size size;
double rotation;
Color color;
}
Here's the notifier:
class Model extends ChangeNotifier {
final List<Box> boxes = [];
void add(Box box) {
boxes.add(box);
notifyListeners();
}
void remove(Box box) {
boxes.remove(box);
notifyListeners();
}
void move(Box box, Offset delta) {
box.center += delta;
notifyListeners();
}
void size(Box box, Offset delta) {
box.size += delta;
notifyListeners();
}
void rotate(Box box, double delta) {
box.rotation += delta;
notifyListeners();
}
}
Now use a ListenableBuilder
to redraw the boxes by placing them according to their position and size in a Stack
. You could also add an InteractiveViewer
to supporting a larger zoomable canvas.
I use a "+" button to create a new box with a random color, centered to the editor. I use a GestureDetector
to move them.
class Editor extends StatelessWidget {
const Editor({super.key, required this.model});
final Model model;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: model,
builder: (context, _) {
return Scaffold(
body: Stack(
children: [
...model.boxes.map((box) {
return Positioned(
left: box.center.dx - box.size.width / 2,
top: box.center.dy - box.size.height / 2,
width: box.size.width,
height: box.size.height,
child: GestureDetector(
onPanUpdate: (details) => model.move(box, details.delta),
child: Container(
color: box.color,
),
),
);
}),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _add(context.size ?? Size.zero),
child: const Icon(Icons.add),
),
);
},
);
}
void _add(Size size) {
model.add(
Box(
center: size.center(Offset.zero),
size: const Size(80, 60),
rotation: 0,
color: [
Colors.blue,
Colors.red,
Colors.amber,
Colors.green,
][model.boxes.length % 4]),
);
}
}
Next we want to support a selection. This can be quite complicated, but we'll add a tapped/clicked box to the selection if its not already part of the selection and remove it otherwise. Tapping/clicking besides all boxes with clear the selection. I'll also add an icon button to select everything.
I extend the Model
to also support a selection.
class Model {
...
final List<Box> selection = [];
void add(Box box) {
boxes.add(box);
selection.add(box);
notifyListeners();
}
void remove(Box box) {
boxes.remove(box);
selection.remove(box);
notifyListeners();
}
...
bool isSelected(Box box) => selection.contains(box);
void select(Box box) {
selection.clear();
selection.add(box);
notifyListeners();
}
void unselect(Box box) {
selection.remove(box);
notifyListeners();
}
void toggle(Box box) {
if (isSelected(box)) {
unselect(box);
} else {
select(box);
}
}
void selectNone() {
selection.clear();
notifyListeners();
}
void selectAll() {
selection.clear();
selection.addAll(boxes);
notifyListeners();
}
}
To show the selection, we create a selection border widget:
class Selection extends StatelessWidget {
const Selection({super.key, required this.child, required this.isVisible});
final Widget child;
final bool isVisible;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
border: isVisible
? Border.all(
color: Colors.deepPurple,
width: 1,
)
: null,
),
child: Padding(
padding: const EdgeInsets.all(2),
child: child,
),
);
}
}
And use it…
Positioned(
left: ... - 2,
top: ... - 2,
width: ... + 4,
height: ... + 4,
child: Selection(
isVisible: model.isSelected(box),
child: ...
)
),
And toggle the selection on tap/click:
GestureDetector(
...
onTapDown: (_) => model.toggle(box),
child: ...
),
Now let's create a "delete" button as an AppBar
action:
Scaffold(
appBar: AppBar(
actions: [
IconButton(
onPressed: model.selection.isNotEmpty ? _remove : null,
icon: const Icon(Icons.delete),
),
],
),
...
with (which should probably be a method of the model):
void _remove() {
for (final box in [...model.selection]) {
model.remove(box);
}
}
Now, instead moving a single box, we should move all selected boxes:
void _move(Offset delta) {
for (final box in model.selection) {
model.move(box, delta);
}
}
However, now the simple approach to toggle the box selection doesn't work anymore because we need to select a box once and then tap/click is again to move it and this would disable its selection.
Also, we could make the Selection
show up to 8 "resize" handles. Based on the position of the handle, the algorithm how to modify the position and size if different and we could also support pressing modifier keys for keeping the aspect ratio or snapping to some grid.
Also, we should provide a properties side panel that displays the common properties of all selected elements. If there are only boxes, this is very simple. However, if you'd support different kinds of elements with different properties, each object should know its properties and then the side panel should know which editor to use for what property and compose its view. Then changing a value will affect all selected elements.