High-performance infinite canvas for Flutter
- Mixed layers: positioned widgets, painter passes, and overlays
- Programmatic camera/item control
- Built for large scenes (node editors, maps, strategy UIs, visual tooling)
Install
dependencies:
infinity_canvas: ^0.9.0
Quickstart
import 'package:flutter/material.dart';
import 'package:infinity_canvas/infinity_canvas.dart';
class MyCanvasPage extends StatefulWidget {
const MyCanvasPage({super.key});
@override
State<MyCanvasPage> createState() => _MyCanvasPageState();
}
class _MyCanvasPageState extends State<MyCanvasPage> {
late final CanvasController controller;
@override
void initState() {
super.initState();
controller = CanvasController(
initialWorldTopLeft: const Offset(-200, -120),
initialZoom: 1.1,
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return InfinityCanvas(
controller: controller,
enableCulling: true,
layers: [
CanvasLayer.positionedItems(
id: 'nodes',
items: [
CanvasItem(
id: 'node-1',
worldPosition: const Offset(120, 100),
behavior: CanvasItemBehavior.nodeEditor(),
child: const _Card('Node 1'),
),
],
),
],
);
}
}
class _Card extends StatelessWidget {
final String title;
const _Card(this.title);
@override
Widget build(BuildContext context) {
return Container(
width: 180,
height: 92,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x3347A3FF)),
),
child: Text(title, style: const TextStyle(color: Colors.white)),
);
}
}
Use Like This
Move items
controller.items.setWorldPosition('node-1', const Offset(300, 240));
controller.items.setWorldPositions({
'node-1': const Offset(300, 240),
'node-2': const Offset(620, 300),
});
To add another item, create another CanvasItem(...) with its own id,
worldPosition, and child, then include it in the items: [...] list.
Auto-sized items and reflow
Items default to CanvasItemSize.auto(), sizing themselves to their child.
When you need to lay them out relative to each other but don't know their sizes
up front (e.g. stacking a column of node-editor cards with variable content),
listen for measured sizes instead of estimating or retrying post-frame:
// Reflow a column whenever any item's measured size changes.
void layoutColumn(CanvasApi api, List<String> ids, {double x = 0, double gap = 100}) {
double y = 0;
final updates = <String, Offset>{};
for (final id in ids) {
final size = api.items.getEffectiveSize(id);
if (size == null) return; // not measured yet — a later signal will retry
updates[id] = Offset(x, y);
y += size.height + gap;
}
api.items.setWorldPositions(updates);
}
// Fires after each measurement, and getEffectiveSize already returns the new
// size when it does — no post-frame settle loop needed.
controller.items.measurementRevision.addListener(() {
layoutColumn(controller, ['a', 'b', 'c']);
});
Use controller.items.measuredSizeListenable(id) instead if you only care about
one item. Both guarantee that getMeasuredSize/getEffectiveSize already
reflect the new size when the signal fires.
Camera controls
controller.camera.jumpToWorldTopLeft(const Offset(-500, -300), zoom: 1.2);
controller.camera.jumpToWorldCenter(const Offset(0, 0), zoom: 0.8);
await controller.camera.animateToWorldCenter(
const Offset(1200, 800),
zoom: 1.0,
duration: const Duration(milliseconds: 420),
);
controller.camera.fitAllItems();
Layer types
InfinityCanvas(
controller: controller,
layers: [
CanvasLayer.painter(
id: 'bg',
painterBuilder: (transform, controller) => MyBackgroundPainter(transform),
),
CanvasLayer.positionedItems(id: 'nodes', items: items),
CanvasLayer.overlay(
id: 'hud',
ignorePointer: false,
builder: (context, transform, controller) => const MyHudWidget(),
),
],
);
Hover, drag, and item transform
CanvasItem(
id: 'node-3',
worldPosition: const Offset(200, 120),
onHoverChanged: (hovered) {
controller.items.setTransform(
'node-3',
hovered ? (Matrix4.identity()..scale(1.04)) : null,
);
},
onDragUpdate: (event) {
// event.worldPosition / event.worldDelta / event.pointerGlobalPosition
},
child: const _Card('Hover me'),
);
Performance Defaults
- Use
enableCulling: truefor larger scenes - Give items a fixed
CanvasItemSizewhere possible - Prefer
CanvasLayer.painterfor very dense static visuals - Use
controller.items.setWorldPositions(...)for batch updates - Keep overlays lean (
CanvasLayer.overlay) for HUD/interaction logic
Example Demos
See example/lib/main.dart:
- Minimal Items
- Painted Item Widgets
- Node Canvas (Clean)
- Grouped Nodes (Linear)
- Input Smoke
- Docking Windows
- Database Schema Designer
- Massive Multi-Widget Art Scene
- Galaxy Trade Map
- Orbital Constellation
API Appendix (Compact)
CanvasController
camera: transform, pan, zoom, fit, jump, animateitems: read diagnostics + mutate item statelayers: show/hide layers
controller.camera
jumpToWorldTopLeft(...)jumpToWorldCenter(...)animateToWorldTopLeft(...)animateToWorldCenter(...)fitWorldRect(...)fitAllItems(...)setScale(...)translateWorld(...)screenToWorld(...)worldToScreen(...)renderStatsListenable
controller.items
getDiagnostics(id)getWorldPosition(id)getMeasuredSize(id)/getEffectiveSize(id)positionListenable(id)measuredSizeListenable(id)measurementRevisionsetWorldPosition(id, offset)setWorldPositions({id: offset})setTransform(id, matrixOrNull)mutateTransform(id, mutator)clearTransform(id)setDragEnabled(id, enabled)bringToFront(id)
CanvasLayer
CanvasLayer.positionedItems(...)CanvasLayer.painter(...)CanvasLayer.overlay(...)
CanvasInputBehavior
CanvasInputBehavior.desktop()CanvasInputBehavior.touch()CanvasInputBehavior.locked()