About flutter-infinite-canvas
Flutter Infinite Canvas is an example for Flutter developers showing how to build an infinite canvas with InteractiveViewer and CustomMultiChildLayout. It also points to an article about creating a multitouch canvas.
Key features
- 01Build a canvas with InteractiveViewer
- 02Arrange children with CustomMultiChildLayout
- 03Demonstrate a multitouch canvas
Use cases
- 01Study an infinite canvas implementation in Flutter
- 02Build a canvas with multitouch interaction
Install
Copy the command and run it in a terminal.
$
npx skills add https:/ / github.com/ rodydavis/ skills --skill flutter-infinite-canvasAfter installing, type/flutter-infinite-canvasin Claude Code to confirm it loaded.
Details
From the project READMEFlutter Infinite Canvas
Overview
The following is an example of how to build an infinite canvas with InteractiveViewer and CustomMultiChildLayout.
Blog post: Create a multi touch canvas in Flutter
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();
}
}
Demo
Comments
Markdown supported