Last active
April 9, 2024 15:04
-
-
Save rodydavis/a7c821504b9760dcd618f7639da68a20 to your computer and use it in GitHub Desktop.
Flutter infinite canvas with InteractiveViewer + CustomMultiChildLayout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:vector_math/vector_math_64.dart' hide Colors; | |
void main() { | |
final controller = WidgetCanvasController([ | |
WidgetCanvasChild( | |
key: UniqueKey(), | |
offset: Offset.zero, | |
size: const Size(400, 800), | |
child: Scaffold( | |
appBar: AppBar( | |
title: const Text('Scaffold 1'), | |
), | |
body: const Placeholder(), | |
), | |
), | |
WidgetCanvasChild( | |
key: UniqueKey(), | |
offset: const Offset(200, 200), | |
size: const Size(400, 800), | |
child: Scaffold( | |
appBar: AppBar( | |
title: const Text('Scaffold 2'), | |
), | |
body: const Placeholder(), | |
), | |
), | |
]); | |
runApp(MaterialApp(home: WidgetCanvas(controller: controller))); | |
} | |
class WidgetCanvas extends StatefulWidget { | |
const WidgetCanvas({super.key, required this.controller}); | |
final WidgetCanvasController controller; | |
@override | |
State<WidgetCanvas> createState() => WidgetCanvasState(); | |
} | |
class WidgetCanvasState extends State<WidgetCanvas> { | |
@override | |
void initState() { | |
super.initState(); | |
controller.addListener(onUpdate); | |
} | |
@override | |
void dispose() { | |
controller.removeListener(onUpdate); | |
super.dispose(); | |
} | |
void onUpdate() { | |
if (mounted) setState(() {}); | |
} | |
static const Size _gridSize = Size.square(50); | |
WidgetCanvasController get controller => widget.controller; | |
Rect axisAlignedBoundingBox(Quad quad) { | |
double xMin = quad.point0.x; | |
double xMax = quad.point0.x; | |
double yMin = quad.point0.y; | |
double yMax = quad.point0.y; | |
for (final Vector3 point in <Vector3>[ | |
quad.point1, | |
quad.point2, | |
quad.point3, | |
]) { | |
if (point.x < xMin) { | |
xMin = point.x; | |
} else if (point.x > xMax) { | |
xMax = point.x; | |
} | |
if (point.y < yMin) { | |
yMin = point.y; | |
} else if (point.y > yMax) { | |
yMax = point.y; | |
} | |
} | |
return Rect.fromLTRB(xMin, yMin, xMax, yMax); | |
} | |
@override | |
Widget build(BuildContext context) { | |
const inset = 2.0; | |
return Listener( | |
onPointerDown: (details) { | |
controller.mouseDown = true; | |
controller.checkSelection(details.localPosition); | |
}, | |
onPointerUp: (details) { | |
controller.mouseDown = false; | |
}, | |
onPointerCancel: (details) { | |
controller.mouseDown = false; | |
}, | |
onPointerMove: (details) {}, | |
child: LayoutBuilder( | |
builder: (context, constraints) => InteractiveViewer.builder( | |
transformationController: controller.transform, | |
panEnabled: controller.canvasMoveEnabled, | |
scaleEnabled: controller.canvasMoveEnabled, | |
onInteractionStart: (details) { | |
controller.mousePosition = details.focalPoint; | |
}, | |
onInteractionUpdate: (details) { | |
if (!controller.mouseDown) { | |
controller.scale = details.scale; | |
} else { | |
controller.moveSelection(details.focalPoint); | |
} | |
controller.mousePosition = details.focalPoint; | |
}, | |
onInteractionEnd: (details) {}, | |
minScale: 0.4, | |
maxScale: 4, | |
boundaryMargin: const EdgeInsets.all(double.infinity), | |
builder: (context, viewport) { | |
return SizedBox( | |
width: 1, | |
height: 1, | |
child: Stack( | |
clipBehavior: Clip.none, | |
children: [ | |
Positioned.fill( | |
child: GridBackgroundBuilder( | |
cellWidth: _gridSize.width, | |
cellHeight: _gridSize.height, | |
viewport: axisAlignedBoundingBox(viewport), | |
), | |
), | |
Positioned.fill( | |
child: CustomMultiChildLayout( | |
delegate: WidgetCanvasDelegate(controller), | |
children: controller.children.map((e) { | |
return LayoutId( | |
id: e, | |
child: Stack( | |
clipBehavior: Clip.none, | |
children: [ | |
Positioned.fill( | |
child: Material( | |
elevation: 4, | |
child: SizedBox.fromSize( | |
size: e.size, | |
child: e.child, | |
), | |
), | |
), | |
if (controller.isSelected(e.key!)) | |
Positioned.fill( | |
top: -inset, | |
left: -inset, | |
right: -inset, | |
bottom: -inset, | |
child: Container( | |
decoration: BoxDecoration( | |
border: Border.all( | |
color: Colors.blue, | |
width: 1, | |
), | |
), | |
), | |
), | |
], | |
)); | |
}).toList(), | |
), | |
), | |
], | |
), | |
); | |
}, | |
), | |
), | |
); | |
} | |
} | |
class GridBackgroundBuilder extends StatelessWidget { | |
const GridBackgroundBuilder({ | |
super.key, | |
required this.cellWidth, | |
required this.cellHeight, | |
required this.viewport, | |
}); | |
final double cellWidth; | |
final double cellHeight; | |
final Rect viewport; | |
@override | |
Widget build(BuildContext context) { | |
final int firstRow = (viewport.top / cellHeight).floor(); | |
final int lastRow = (viewport.bottom / cellHeight).ceil(); | |
final int firstCol = (viewport.left / cellWidth).floor(); | |
final int lastCol = (viewport.right / cellWidth).ceil(); | |
return Stack( | |
clipBehavior: Clip.none, | |
children: <Widget>[ | |
for (int row = firstRow; row < lastRow; row++) | |
for (int col = firstCol; col < lastCol; col++) | |
Positioned( | |
left: col * cellWidth, | |
top: row * cellHeight, | |
child: Container( | |
height: cellHeight, | |
width: cellWidth, | |
decoration: BoxDecoration( | |
border: Border.all( | |
color: Colors.grey.withOpacity(0.1), | |
width: 1, | |
), | |
), | |
), | |
), | |
], | |
); | |
} | |
} | |
class WidgetCanvasDelegate extends MultiChildLayoutDelegate { | |
WidgetCanvasDelegate(this.controller); | |
final WidgetCanvasController controller; | |
List<WidgetCanvasChild> get children => controller.children; | |
Size backgroundSize = const Size(100000, 100000); | |
late Offset backgroundOffset = Offset( | |
-backgroundSize.width / 2, | |
-backgroundSize.height / 2, | |
); | |
@override | |
void performLayout(Size size) { | |
// Then draw the screens. | |
for (final widget in children) { | |
layoutChild(widget, BoxConstraints.tight(widget.size)); | |
positionChild(widget, widget.offset); | |
} | |
} | |
@override | |
bool shouldRelayout(WidgetCanvasDelegate oldDelegate) => true; | |
} | |
class WidgetCanvasChild extends StatelessWidget { | |
const WidgetCanvasChild({ | |
required Key key, | |
required this.size, | |
required this.offset, | |
required this.child, | |
}) : super(key: key); | |
final Size size; | |
final Offset offset; | |
final Widget child; | |
Rect get rect => offset & size; | |
WidgetCanvasChild copyWith({ | |
Size? size, | |
Offset? offset, | |
Widget? child, | |
}) { | |
return WidgetCanvasChild( | |
key: key!, | |
size: size ?? this.size, | |
offset: offset ?? this.offset, | |
child: child ?? this.child, | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return child; | |
} | |
} | |
class WidgetCanvasController extends ChangeNotifier { | |
WidgetCanvasController(this.children); | |
final List<WidgetCanvasChild> children; | |
final Set<Key> _selected = {}; | |
late final transform = TransformationController(); | |
Matrix4 get matrix => transform.value; | |
double scale = 1; | |
Offset mousePosition = Offset.zero; | |
bool _mouseDown = false; | |
bool get mouseDown => _mouseDown; | |
set mouseDown(bool value) { | |
_mouseDown = value; | |
notifyListeners(); | |
} | |
bool isSelected(Key key) => _selected.contains(key); | |
bool get hasSelection => _selected.isNotEmpty; | |
bool get canvasMoveEnabled => !mouseDown; | |
Offset toLocal(Offset global) { | |
return transform.toScene(global); | |
} | |
void checkSelection(Offset localPosition) { | |
final offset = toLocal(localPosition); | |
final selection = <Key>[]; | |
for (final child in children) { | |
final rect = child.rect; | |
if (rect.contains(offset)) { | |
selection.add(child.key!); | |
} | |
} | |
if (selection.isNotEmpty) { | |
setSelection({selection.last}); | |
} else { | |
deselectAll(); | |
} | |
} | |
void moveSelection(Offset position) { | |
final delta = toLocal(position) - toLocal(mousePosition); | |
for (final key in _selected) { | |
final index = children.indexWhere((e) => e.key == key); | |
if (index == -1) continue; | |
final current = children[index]; | |
children[index] = current.copyWith( | |
offset: current.offset + delta, | |
); | |
} | |
mousePosition = position; | |
notifyListeners(); | |
} | |
void select(Key key) { | |
_selected.add(key); | |
notifyListeners(); | |
} | |
void setSelection(Set<Key> keys) { | |
_selected.clear(); | |
_selected.addAll(keys); | |
notifyListeners(); | |
} | |
void deselect(Key key) { | |
_selected.remove(key); | |
notifyListeners(); | |
} | |
void deselectAll() { | |
_selected.clear(); | |
notifyListeners(); | |
} | |
void add(WidgetCanvasChild child) { | |
children.add(child); | |
notifyListeners(); | |
} | |
void remove(Key key) { | |
children.removeWhere((e) => e.key == key); | |
notifyListeners(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Demo:
https://dartpad.dev/?id=a7c821504b9760dcd618f7639da68a20&channel=master