Building Interactive Node Graphs in Flutter with Voo Node Canvas
Visual node editors power everything from Blender's shader graphs to AWS's visual workflow builder. They turn complex logic into draggable boxes and connections that non-technical users can actually understand. But if you've tried building one in Flutter, you know it's a nightmare of custom painters, gesture detection, and coordinate math.
Voo Node Canvas solves this with a widget that handles the heavy lifting. At version 0.1.4, it's still early but already handles the core challenges: draggable nodes, connection management, and canvas navigation.
The Real Problem This Solves
Let's say you're building a workflow automation tool for AgileStack clients. Business users need to create approval chains: "When invoice > $5000, route to manager, then CFO, then back to requester." A traditional form-based UI would need dozens of conditional dropdowns. A node graph makes it visual and intuitive.
Or consider NutriScan's meal planning feature. Users want to chain recipes based on prep time, dietary restrictions, and ingredient overlap. Again, nodes and connections tell the story better than any menu system.
The alternatives aren't great. You could use flutter_flow_chart (abandoned since 2021) or build from scratch with CustomPainter. Most teams choose the latter and spend weeks on basic pan/zoom before touching actual business logic.
Quick Start: Your First Node Graph
Install the package:
flutter pub add voo_node_canvas
Here's a minimal working example:
import 'package:flutter/material.dart';
import 'package:voo_node_canvas/voo_node_canvas.dart';
class SimpleNodeGraph extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: VooNodeCanvas(
nodes: [
NodeData(
id: 'start',
position: Offset(100, 200),
size: Size(120, 80),
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text('Start', style: TextStyle(color: Colors.white))),
),
),
NodeData(
id: 'process',
position: Offset(300, 200),
size: Size(120, 80),
child: Container(
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text('Process', style: TextStyle(color: Colors.white))),
),
),
],
connections: [
ConnectionData(
from: 'start',
to: 'process',
fromPort: 'output',
toPort: 'input',
),
],
),
);
}
}
This gives you two draggable nodes with a connection line. The canvas supports pan and zoom out of the box.
Core Concepts and Mental Model
Voo Node Canvas operates on three main concepts:
Nodes are rectangular widgets positioned on a 2D canvas. Each node has an ID, position, size, and child widget. Think of them as floating containers that users can drag around.
Connections link nodes together via ports. A connection defines source node, target node, and optionally specific ports on each. The canvas draws bezier curves between connected points.
The Canvas itself handles coordinate transforms, gesture recognition, and rendering order. It maintains zoom level, pan offset, and hit-testing for interactions.
The widget uses a declarative approach. You pass lists of NodeData and ConnectionData objects, and it figures out the rendering. When users drag nodes, you'll get callbacks with new positions to update your state.
Realistic Example 1: Workflow Builder
Let's build something closer to production: a simple workflow editor for document approvals.
class WorkflowEditor extends StatefulWidget {
@override
_WorkflowEditorState createState() => _WorkflowEditorState();
}
class _WorkflowEditorState extends State<WorkflowEditor> {
List<WorkflowNode> nodes = [];
List<WorkflowConnection> connections = [];
@override
void initState() {
super.initState();
_initializeWorkflow();
}
void _initializeWorkflow() {
nodes = [
WorkflowNode(
id: 'submit',
type: WorkflowNodeType.trigger,
title: 'Document Submitted',
position: Offset(50, 200),
),
WorkflowNode(
id: 'check_amount',
type: WorkflowNodeType.condition,
title: 'Amount > $5000?',
position: Offset(300, 200),
),
WorkflowNode(
id: 'manager_review',
type: WorkflowNodeType.approval,
title: 'Manager Review',
position: Offset(550, 120),
),
WorkflowNode(
id: 'auto_approve',
type: WorkflowNodeType.action,
title: 'Auto Approve',
position: Offset(550, 280),
),
];
connections = [
WorkflowConnection(from: 'submit', to: 'check_amount'),
WorkflowConnection(from: 'check_amount', to: 'manager_review', condition: 'yes'),
WorkflowConnection(from: 'check_amount', to: 'auto_approve', condition: 'no'),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Workflow Editor')),
body: VooNodeCanvas(
nodes: nodes.map((node) => NodeData(
id: node.id,
position: node.position,
size: Size(140, 80),
child: _buildNodeWidget(node),
)).toList(),
connections: connections.map((conn) => ConnectionData(
from: conn.from,
to: conn.to,
label: conn.condition,
)).toList(),
onNodeMoved: (String nodeId, Offset newPosition) {
setState(() {
final nodeIndex = nodes.indexWhere((n) => n.id == nodeId);
if (nodeIndex != -1) {
nodes[nodeIndex] = nodes[nodeIndex].copyWith(position: newPosition);
}
});
},
onConnectionTapped: (String connectionId) {
_showConnectionEditor(connectionId);
},
),
);
}
Widget _buildNodeWidget(WorkflowNode node) {
Color color = _getNodeColor(node.type);
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
padding: EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(_getNodeIcon(node.type), color: Colors.white, size: 20),
SizedBox(height: 4),
Text(
node.title,
style: TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center,
),
],
),
);
}
}
This example shows how you'd handle different node types, user interactions, and state management in a real application.
Realistic Example 2: Data Pipeline Visualizer
Another common use case is visualizing data transformations. Here's how you might build a pipeline editor for ETL workflows:
class DataPipelineCanvas extends StatefulWidget {
final List<PipelineStage> stages;
final Function(String stageId, Map<String, dynamic> config)? onStageConfigured;
const DataPipelineCanvas({
Key? key,
required this.stages,
this.onStageConfigured,
}) : super(key: key);
@override
_DataPipelineCanvasState createState() => _DataPipelineCanvasState();
}
class _DataPipelineCanvasState extends State<DataPipelineCanvas> {
double canvasZoom = 1.0;
Offset canvasOffset = Offset.zero;
@override
Widget build(BuildContext context) {
return VooNodeCanvas(
nodes: widget.stages.map((stage) => NodeData(
id: stage.id,
position: stage.position,
size: _calculateNodeSize(stage),
child: _buildPipelineStageNode(stage),
)).toList(),
connections: _buildConnections(),
showGrid: true,
gridColor: Colors.grey.shade200,
onNodeDoubleRap: (String nodeId) {
_showStageConfiguration(nodeId);
},
onCanvasRangedToCreate: (Offset position) {
_showAddStageDialog(position);
},
);
}
Widget _buildPipelineStageNode(PipelineStage stage) {
return Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_getStageIcon(stage.type), size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
stage.name,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (stage.hasErrors) Icon(Icons.error, color: Colors.red, size: 16),
],
),
SizedBox(height: 8),
Text(
'${stage.inputRecords ?? 0} records in',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
Text(
'${stage.outputRecords ?? 0} records out',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
if (stage.processingTime != null)
Text(
'Avg: ${stage.processingTime}ms',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
),
);
}
}
This pattern works well for any domain where you need to show data flow: API request chains, image processing pipelines, or even CI/CD stage visualization.
Performance Characteristics at Scale
Voo Node Canvas performs reasonably well up to about 100-150 nodes on modern devices. Beyond that, you'll hit some limitations:
Rendering Cost: Every node is a full Flutter widget, so complex node content (lots of text, images, or nested layouts) adds up quickly. We've seen frame drops around 200+ nodes with rich content.
Memory Usage: The canvas keeps all nodes in memory simultaneously. In testing, 500 simple nodes consumed about 45MB of additional RAM on Android.
Hit Testing: Node selection uses basic bounds checking, which is O(n) for every tap. This becomes noticeable above 300 nodes, especially on lower-end devices.
Connection Rendering: Bezier curve calculations scale linearly with connection count. 1000+ connections will impact smooth panning, dropping from 60fps to around 35fps on a Pixel 4.
If you need to handle larger graphs, consider implementing virtualization (only render visible nodes) or LOD (level-of-detail) switching where distant nodes show simplified representations.
Common Gotchas and Integration Tips
Pin your version to a specific minor release:
dependencies:
voo_node_canvas: ^0.1.4
The API is still evolving, so patch versions might introduce breaking changes.
Coordinate System Confusion: The canvas uses local coordinates for node positions, but Flutter widgets use global coordinates for gestures. When handling custom tap detection inside nodes, remember to account for canvas transform.
State Management: Node positions update frequently during dragging. Don't trigger expensive operations (like API calls) on every onNodeMoved callback. Use debouncing or only persist changes when dragging stops.
Z-Index Issues: Newly added nodes appear above existing ones. If you need custom layering, you'll need to manage node order in your data structure and rebuild the canvas.
Connection Port Alignment: The library assumes connection points are at node centers by default. For custom port positions (like inputs on the left, outputs on the right), you'll need to calculate offsets manually.
Where It Shines and Where It Doesn't
Voo Node Canvas excels at:
- Rapid prototyping of node-based UIs
- Small to medium graphs (under 100 nodes)
- Business workflow visualization
- Educational tools and demos
It struggles with:
- Large-scale graphs (think Blender's 1000+ node materials)
- Real-time data visualization requiring 60fps updates
- Complex port management (multiple input/output types per node)
- Advanced features like node grouping, minimap, or copy/paste
The package is honest about being early-stage. It handles the fundamentals well but lacks the polish and feature depth of mature solutions like React Flow or Cytoscape.js.
Making the Decision
If you're building a Flutter app that needs basic node graph functionality, Voo Node Canvas saves weeks of custom development. The API is clean, the performance is adequate for most use cases, and it integrates naturally with Flutter's widget system.
For complex visual editors or large-scale graphs, you might still need a custom solution or web-based alternative. But for business applications, prototypes, and moderate-complexity workflows, it's the best option available in the Flutter ecosystem.
The 20 monthly downloads suggest it's still flying under the radar, which could be good (less churn) or concerning (less community support) depending on your risk tolerance.
Start with the quick example above, build out your node types, and see how it feels. The worst case is a few hours of experimentation. The best case is avoiding weeks of reinventing canvas math.
Want to dig deeper into voo_node_canvas? Check out the package page for full docs and live stats. Need help integrating it into your stack? AgileStack helps teams adopt the right tools without the consulting-firm overhead. Book a 30-minute call.