canvas_kit 0.5.1
canvas_kit: ^0.5.1 copied to clipboard
Composable infinite pan/zoom canvas for Flutter with interactive and programmatic camera control.
Canvas Kit — Infinite Canvas for Flutter
Composable infinite pan/zoom canvas for Flutter with interactive and programmatic camera control
Quick Demo #
Canvas Kit User Guide #
A step-by-step guide to using the canvas_kit
package to build pan/zoom interfaces.
What is an Canvas Kit? #
An Canvas Kit is a zoomable, pannable interface where content exists in a large 2D coordinate space. Think of it like Google Maps, but for your app's content. Users can:
- Pan by dragging to move around the space
- Zoom with pinch gestures or mouse wheel to see more or less detail
- Interact with content at different zoom levels
Common use cases:
- Node editors - Visual programming, flowcharts, mind maps
- Design tools - Drawing apps, CAD software, graphic editors
- Data visualization - Large graphs, network diagrams, timelines
- Games - Strategy games, world maps, level editors
- Productivity - Whiteboards, planning tools, brainstorming apps
Why Use This Package? #
** What makes it great:**
- Drop-in solution - Works with any Flutter widgets
- Two interaction modes - Automatic gestures or full control
- World + viewport mixing - Content that moves with zoom + fixed UI elements
- Boundary support - Constrain users to specific areas
- Performance optimized - Automatic culling and efficient rendering
- Gesture flexibility - Handles complex multi-touch scenarios correctly
** When you might want something else:**
- Simple scrolling lists - Use
ListView
orGridView
instead - Fixed-size content - Regular Flutter layouts are simpler
- Text-heavy interfaces - Zooming text can hurt readability
- Touch-only simple apps - May be overkill for basic interactions
Compared to building your own:
- Handles tricky gesture conflicts (pan vs zoom vs item drag)
- Solves coordinate transformation math for you
- Provides optimizations you probably wouldn't implement
- Maintains consistent behavior across platforms
What you'll learn:
- Getting Started - Your first Canvas Kit2. Adding Interactive Items - Draggable nodes and widgets
- Understanding Coordinate Systems - World vs viewport
- Camera Control - Programmatic navigation
- Setting Boundaries - Constraining the view
- Visual Layers - Backgrounds and overlays
- Advanced Usage - Custom gestures and complex layouts
Important Notes #
- Initial release and evolving API: This is an early version of the package. The public API may evolve and include breaking changes. To avoid surprise upgrades, lock the package version in your
pubspec.yaml
. - Platform focus: Most testing has been done on desktop and web. For the smoothest experience, validate your flows on desktop/web first, then adapt to mobile as needed.
- Issues and feedback: Please report bugs and feature requests via GitHub Issues in the repository. Include minimal repro steps and your environment (Flutter version, platform) to help us triage quickly.
Getting Started #
Step 1: Basic Setup #
Start with the simplest possible Canvas Kit - just a container you can pan and zoom:
import 'package:flutter/material.dart';
import 'package:canvas_kit/canvas_kit.dart';
class MyCanvasPage extends StatefulWidget {
@override
State<MyCanvasPage> createState() => _MyCanvasPageState();
}
class _MyCanvasPageState extends State<MyCanvasPage> {
late final CanvasKitController _controller;
@override
void initState() {
super.initState();
_controller = CanvasKitController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My Canvas Kit')),
body: CanvasKit(
controller: _controller,
children: [],
),
);
}
}
What's happening: This creates an empty Canvas Kit with automatic pan (drag) and zoom (pinch/wheel) gestures.
Step 2: Add a Visual Background #
Add a grid background so you can actually see the pan/zoom in action:
import 'package:vector_math/vector_math_64.dart' show Vector3;
class SimpleGrid extends CustomPainter {
final Matrix4 transform;
SimpleGrid(this.transform);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.withValues(alpha: 0.3)
..strokeWidth = 1;
// Draw grid lines every 50 units
for (double x = -1000; x <= 1000; x += 50) {
final start = _worldToScreen(Offset(x, -1000));
final end = _worldToScreen(Offset(x, 1000));
if (start.dx >= -100 && start.dx <= size.width + 100) {
canvas.drawLine(start, end, paint);
}
}
for (double y = -1000; y <= 1000; y += 50) {
final start = _worldToScreen(Offset(-1000, y));
final end = _worldToScreen(Offset(1000, y));
if (start.dy >= -100 && start.dy <= size.height + 100) {
canvas.drawLine(start, end, paint);
}
}
}
Offset _worldToScreen(Offset worldPoint) {
final vector = Vector3(worldPoint.dx, worldPoint.dy, 0);
vector.applyMatrix4(transform);
return Offset(vector.x, vector.y);
}
@override
bool shouldRepaint(SimpleGrid old) => old.transform != transform;
}
// Use in your CanvasKit:
CanvasKit(
controller: _controller,
backgroundBuilder: (transform) => Container(
color: const Color(0xFFF8F8F8), // Light gray base
child: CustomPaint(
painter: SimpleGrid(transform),
size: Size.infinite,
),
),
children: [],
)
Try it: Now when you drag and zoom, you'll see the grid lines move and scale, giving you clear visual feedback of the camera movement.
Adding Interactive Items #
Step 3: Your First Canvas Item #
Add some content to your canvas:
import 'package:flutter/material.dart';
import 'package:canvas_kit/canvas_kit.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;
class MyCanvasPage extends StatefulWidget {
@override
State<MyCanvasPage> createState() => _MyCanvasPageState();
}
class _MyCanvasPageState extends State<MyCanvasPage> {
late final CanvasKitController _controller;
Offset _nodePosition = const Offset(200, 150);
@override
void initState() {
super.initState();
_controller = CanvasKitController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My Canvas Kit')),
body: CanvasKit(
controller: _controller,
backgroundBuilder: (transform) => Container(
color: const Color(0xFFF8F8F8),
child: CustomPaint(
painter: SimpleGrid(transform),
size: Size.infinite,
),
),
children: [
CanvasItem(
id: 'my-node',
worldPosition: _nodePosition,
child: Container(
width: 100,
height: 80,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text('Hello!', style: TextStyle(color: Colors.white)),
),
),
),
],
),
);
}
}
class SimpleGrid extends CustomPainter {
final Matrix4 transform;
SimpleGrid(this.transform);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.withValues(alpha: 0.3)
..strokeWidth = 1;
for (double x = -1000; x <= 1000; x += 50) {
final start = _worldToScreen(Offset(x, -1000));
final end = _worldToScreen(Offset(x, 1000));
if (start.dx >= -100 && start.dx <= size.width + 100) {
canvas.drawLine(start, end, paint);
}
}
for (double y = -1000; y <= 1000; y += 50) {
final start = _worldToScreen(Offset(-1000, y));
final end = _worldToScreen(Offset(1000, y));
if (start.dy >= -100 && start.dy <= size.height + 100) {
canvas.drawLine(start, end, paint);
}
}
}
Offset _worldToScreen(Offset worldPoint) {
final vector = Vector3(worldPoint.dx, worldPoint.dy, 0);
vector.applyMatrix4(transform);
return Offset(vector.x, vector.y);
}
@override
bool shouldRepaint(SimpleGrid old) => old.transform != transform;
}
What's happening: The CanvasItem
places your widget at position (200, 150) in "world space" - it will move and scale with the canvas.
Step 4: Make Items Draggable #
Enable dragging by adding two properties to your existing CanvasItem:
CanvasItem(
id: 'my-node',
worldPosition: _nodePosition,
draggable: true, // Enable dragging
onWorldMoved: (newPosition) {
setState(() {
_nodePosition = newPosition; // Update your state
});
},
child: Container(
width: 100,
height: 80,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text('Hello!', style: TextStyle(color: Colors.white)),
),
),
)
Try it: Now you can drag the blue box around while still being able to pan and zoom the canvas.
Step 5: Add Multiple Items #
Update your state class to handle multiple draggable nodes:
class _MyCanvasPageState extends State<MyCanvasPage> {
late final CanvasKitController _controller;
Offset _node1Position = const Offset(100, 100);
Offset _node2Position = const Offset(300, 200);
@override
void initState() {
super.initState();
_controller = CanvasKitController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My Canvas Kit')),
body: CanvasKit(
controller: _controller,
backgroundBuilder: (transform) => Container(
color: const Color(0xFFF8F8F8),
child: CustomPaint(
painter: SimpleGrid(transform),
size: Size.infinite,
),
),
children: [
CanvasItem(
id: 'node-1',
worldPosition: _node1Position,
draggable: true,
onWorldMoved: (pos) => setState(() => _node1Position = pos),
child: _buildNode('Node 1', Colors.blue),
),
CanvasItem(
id: 'node-2',
worldPosition: _node2Position,
draggable: true,
onWorldMoved: (pos) => setState(() => _node2Position = pos),
child: _buildNode('Node 2', Colors.green),
),
],
),
);
}
Widget _buildNode(String text, Color color) {
return Container(
width: 100,
height: 80,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(text, style: const TextStyle(color: Colors.white)),
),
);
}
}
Understanding Coordinate Systems #
World Space vs Viewport Space #
- World space: Your content's logical coordinates (like a map or game world)
- Viewport space: The device screen coordinates (for UI that stays in place)
Step 6: Add a Fixed UI Element #
Add a toolbar that stays in the corner while the canvas moves:
children: [
// Your existing world-space nodes...
// Fixed viewport element
CanvasItem(
id: 'toolbar',
anchor: CanvasAnchor.viewport, // Stays on screen
viewportPosition: const Offset(16, 16), // Top-left corner
lockZoom: true, // Doesn't scale with zoom
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
),
child: const Text('Toolbar'),
),
),
]
What's happening: The toolbar stays in the top-left corner and maintains its size regardless of zoom level.
Camera Control #
Step 7: Programmatic Navigation #
Add buttons to control the camera programmatically:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Canvas Kit'),
actions: [
IconButton(
icon: const Icon(Icons.zoom_out),
onPressed: () {
_controller.setScale(_controller.scale * 0.8);
},
),
IconButton(
icon: const Icon(Icons.zoom_in),
onPressed: () {
_controller.setScale(_controller.scale * 1.2);
},
),
IconButton(
icon: const Icon(Icons.center_focus_strong),
onPressed: () {
// Center on first node
final size = MediaQuery.of(context).size;
_controller.centerOn(_node1Position, size);
},
),
],
),
body: CanvasKit(/* ... */),
);
}
Step 8: Fit Camera to Content #
Automatically frame all your content:
void _fitToAllNodes() {
final positions = [_node1Position, _node2Position];
final size = MediaQuery.of(context).size;
_controller.fitToPositions(positions, size, padding: 50);
}
Common camera operations:
centerOn(worldPoint, screenSize)
- Center on a specific world positionfitToPositions(positions, screenSize)
- Frame multiple pointssetScale(newScale)
- Change zoom leveltranslateWorld(delta)
- Move the camera
Setting Boundaries #
Step 9: Constrain the Canvas #
Prevent users from panning infinitely by setting boundaries:
class _MyCanvasPageState extends State<MyCanvasPage> {
static const worldBounds = Rect.fromLTWH(-200, -200, 800, 600);
late final CanvasKitController _controller;
@override
void initState() {
super.initState();
_controller = CanvasKitController(
bounds: worldBounds, // Set the boundary
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CanvasKit(
controller: _controller,
bounds: worldBounds, // Apply to widget too
autoFitToBounds: true, // Start by showing all bounds
boundsFitPadding: 40, // Add some padding
children: [/* your items */],
),
);
}
}
What's happening: Users can only pan/zoom within the specified rectangle. The camera automatically fits to show the entire bounds when first loaded.
Visual Layers #
Step 10: Add a Grid Background #
Create a visual grid to help users navigate:
class GridPainter extends CustomPainter {
final Matrix4 transform;
final double spacing;
GridPainter(this.transform, this.spacing);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.withValues(alpha: 0.3)
..strokeWidth = 1;
// Transform world grid lines to screen coordinates
for (double x = -1000; x <= 1000; x += spacing) {
final start = _worldToScreen(Offset(x, -1000));
final end = _worldToScreen(Offset(x, 1000));
canvas.drawLine(start, end, paint);
}
for (double y = -1000; y <= 1000; y += spacing) {
final start = _worldToScreen(Offset(-1000, y));
final end = _worldToScreen(Offset(1000, y));
canvas.drawLine(start, end, paint);
}
}
Offset _worldToScreen(Offset worldPoint) {
final vector = Vector3(worldPoint.dx, worldPoint.dy, 0);
vector.applyMatrix4(transform);
return Offset(vector.x, vector.y);
}
@override
bool shouldRepaint(GridPainter old) => old.transform != transform;
}
// Use in your CanvasKit:
CanvasKit(
controller: _controller,
backgroundBuilder: (transform) => CustomPaint(
painter: GridPainter(transform, 100), // 100-unit grid
),
children: [/* your items */],
)
Step 11: Add Connection Lines #
Draw lines between your nodes using a foreground layer:
class ConnectionPainter extends CustomPainter {
final Matrix4 transform;
final List<Offset> points;
ConnectionPainter(this.transform, this.points);
@override
void paint(Canvas canvas, Size size) {
if (points.length < 2) return;
final paint = Paint()
..color = Colors.blue
..strokeWidth = 2
..style = PaintingStyle.stroke;
for (int i = 0; i < points.length - 1; i++) {
final start = _worldToScreen(points[i]);
final end = _worldToScreen(points[i + 1]);
canvas.drawLine(start, end, paint);
}
}
Offset _worldToScreen(Offset worldPoint) {
final vector = Vector3(worldPoint.dx, worldPoint.dy, 0);
vector.applyMatrix4(transform);
return Offset(vector.x, vector.y);
}
@override
bool shouldRepaint(ConnectionPainter old) =>
old.transform != transform || old.points != points;
}
// Use in your CanvasKit:
CanvasKit(
controller: _controller,
foregroundLayers: [
(transform) => ConnectionPainter(transform, [_node1Position, _node2Position]),
],
children: [/* your items */],
)
What's happening: Foreground layers draw on top of your items and automatically ignore touch events.
Advanced Usage #
Custom Gesture Handling #
For full control over touch interactions, switch to programmatic mode and handle gestures yourself:
class _CustomGestureOverlay extends StatefulWidget {
final CanvasKitController controller;
const _CustomGestureOverlay(this.controller);
@override
State<_CustomGestureOverlay> createState() => _CustomGestureOverlayState();
}
class _CustomGestureOverlayState extends State<_CustomGestureOverlay> {
double? _initialScale;
Offset? _focalPoint;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: (details) {
_initialScale = widget.controller.scale;
_focalPoint = widget.controller.screenToWorld(details.localFocalPoint);
},
onScaleUpdate: (details) {
if (details.pointerCount == 1) {
// Single finger - pan
final worldDelta = widget.controller.deltaScreenToWorld(details.focalPointDelta);
widget.controller.translateWorld(worldDelta);
} else {
// Multi-finger - zoom around focal point
final newScale = _initialScale! * details.scale;
widget.controller.setScale(newScale, focalWorld: _focalPoint!);
// Adjust position to keep focal point stable
final currentScreen = widget.controller.worldToScreen(_focalPoint!);
final targetScreen = details.localFocalPoint;
final correction = widget.controller.deltaScreenToWorld(targetScreen - currentScreen);
widget.controller.translateWorld(correction);
}
},
child: const SizedBox.expand(),
);
}
}
// Use in CanvasKit:
CanvasKit(
controller: _controller,
interactionMode: InteractionMode.programmatic,
gestureOverlayBuilder: (transform, controller) => _CustomGestureOverlay(controller),
children: [/* your items */],
)
Performance Tips #
For large canvases with many items, use these optimizations:
- Set estimated sizes for large items:
CanvasItem(
id: 'large-map',
worldPosition: mapPosition,
estimatedSize: const Size(2000, 2000), // Helps with culling
child: MyLargeMapWidget(),
)
- Use viewport anchoring for UI elements:
CanvasItem(
id: 'minimap',
anchor: CanvasAnchor.viewport,
viewportPosition: const Offset(16, 16),
lockZoom: true, // UI doesn't need to scale
child: MinimapWidget(),
)
Quick Reference #
Controller Methods #
setScale(double scale, {Offset? focalWorld})
- Change zoom leveltranslateWorld(Offset delta)
- Move cameracenterOn(Offset worldPoint, Size viewport)
- Center on positionfitToPositions(List<Offset> positions, Size viewport, {double padding = 0})
- Frame multiple pointsscreenToWorld(Offset screenPoint)
- Convert screen to world coordinatesworldToScreen(Offset worldPoint)
- Convert world to screen coordinates
CanvasItem Properties #
worldPosition
- Position in world coordinates (for world-anchored items)viewportPosition
- Position in screen coordinates (for viewport-anchored items)anchor
-CanvasAnchor.world
(default) orCanvasAnchor.viewport
draggable
- Enable automatic dragging (interactive mode only)onWorldMoved
/onViewportMoved
- Callbacks when item position changeslockZoom
- Keep constant size regardless of zoom (viewport items only)estimatedSize
- Size hint for performance optimization
Interaction Modes #
InteractionMode.interactive
(default) - Package handles pan/zoom automaticallyInteractionMode.programmatic
- Your app controls all gestures viagestureOverlayBuilder
What's Next? #
Now that you've learned the basics, explore the example demos in the package:
Run the examples:
cd example
flutter run
Key examples to study:
- Interactive Demo - Basic draggable nodes with package-owned gestures
- Programmatic Demo - Custom gesture handling and camera controls
- Bounds Demo - Constrained panning and auto-fit functionality
- Node Editor Demo - Complex UI with connection wires and custom painting
- Snake Demo - Animated content and camera following
Each example demonstrates different aspects of the package and provides copy-pasteable code patterns you can adapt for your own projects.