Vyuh Node Flow

A flexible, high-performance node-based flow editor for Flutter applications, inspired by React Flow. Build visual programming interfaces, workflow editors, interactive diagrams, and data pipelines with ease.
Try the Demo
Experience Vyuh Node Flow in action! The live demo showcases all key features, including node creation, drag-and-drop connections, custom theming, annotations, minimap, and more.
✨ Key Features
- High Performance - Reactive, optimized rendering for smooth interactions
- Type-Safe Node Data - Generic type support for strongly-typed node data
- Fully Customizable - Comprehensive theming system for nodes, connections, and ports
- Flexible Ports - Multiple port shapes, positions, and connection validation
- Annotations - Add labels, notes, and custom overlays to your flow
- Minimap - Built-in minimap for navigation in complex flows
- Keyboard Shortcuts - Full keyboard support for power users
- Connection Styles - Multiple connection path styles (bezier, smoothstep, straight)
- Read-Only Viewer - Display flows without editing capabilities
- Serialization - Save and load flows from JSON
Screenshots
Installation
Add to your pubspec.yaml:
dependencies:
vyuh_node_flow: any
Then run:
flutter pub get
🚀 Quick Start
Here's a minimal example to get you started:
import 'package:flutter/material.dart';
import 'package:vyuh_node_flow/vyuh_node_flow.dart';
class SimpleFlowEditor extends StatefulWidget {
@override
State<SimpleFlowEditor> createState() => _SimpleFlowEditorState();
}
class _SimpleFlowEditorState extends State<SimpleFlowEditor> {
late final NodeFlowController<String> controller;
@override
void initState() {
super.initState();
// 1. Create the controller
controller = NodeFlowController<String>();
// 2. Add some nodes
controller.addNode(Node<String>(
id: 'node-1',
type: 'input',
position: const Offset(100, 100),
data: 'Input Node',
outputPorts: const [Port(id: 'out', name: 'Output')],
));
controller.addNode(Node<String>(
id: 'node-2',
type: 'output',
position: const Offset(400, 100),
data: 'Output Node',
inputPorts: const [Port(id: 'in', name: 'Input')],
));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: NodeFlowEditor<String>(
controller: controller,
theme: NodeFlowTheme.light,
nodeBuilder: (context, node) => _buildNode(node),
),
);
}
Widget _buildNode(Node<String> node) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(node.data),
);
}
}
Core Concepts
The Controller
The NodeFlowController is the central piece that manages all state:
final controller = NodeFlowController<T>(
config: NodeFlowConfig(
snapToGrid: true,
gridSize: 20.0,
minZoom: 0.5,
maxZoom: 2.0,
),
initialViewport: GraphViewport(x: 0, y: 0, zoom: 1.0),
);
!TIPThe type parameter<T>represents the data type stored in each node. We recommend using a * *sealed class hierarchy** with multiple subclasses to create a strongly-typed collection of node types that work together. This provides excellent type safety and pattern matching capabilities.
Controller API Reference
Managing Nodes
// Add a node
controller.addNode
(
node);
// Remove a node
controller.removeNode(nodeId);
// Get a node
final node = controller.getNode(nodeId);
// Set node position
controller.setNodePosition(nodeId, newPosition);
// Select nodes
controller.selectNode(nodeId);
controller.selectNodes([nodeId1, nodeId2]);
controller.clearSelection();
Managing Connections
// Add a connection
controller.addConnection
(
connection);
// Remove a connection
controller.removeConnection(connectionId);
// Get connections for a node
final connections
=
controller
.
getConnectionsForNode
(
nodeId
);
Viewport Control
// Pan and zoom
controller.setViewport
(
GraphViewport(x: 100, y: 100, zoom: 1.5));
controller.zoomBy(0.1); // Zoom in
controller.zoomBy(-0.1); // Zoom out
controller.zoomTo(1.5); // Set specific zoom level
controller.fitToView(); // Fit all nodes in view
controller.centerOnNode(nodeId); // Center on specific node
Graph Operations
// Load/save graph
final graph = controller.exportGraph();
controller.loadGraph
(
graph
);
// Clear everything
controller
.
clearGraph
(
);
Theming
Using Built-in Themes
// Light theme
controller.setTheme
(
NodeFlowTheme.light);
// Dark theme
controller.setTheme(NodeFlowTheme.dark
);
Creating Custom Themes
Complete Custom Theme Example
final customTheme = NodeFlowTheme(
// Node appearance
nodeTheme: NodeTheme(
backgroundColor: Colors.white,
selectedBackgroundColor: Colors.blue.shade50,
borderColor: Colors.grey.shade300,
selectedBorderColor: Colors.blue,
borderWidth: 1.0,
selectedBorderWidth: 2.0,
borderRadius: BorderRadius.circular(8.0),
padding: const EdgeInsets.all(16.0),
titleStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
contentStyle: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
// Connection appearance
connectionStyle: ConnectionStyles.smoothstep,
connectionTheme: ConnectionTheme(
color: Colors.blue.shade300,
selectedColor: Colors.blue.shade700,
strokeWidth: 2.0,
selectedStrokeWidth: 3.0,
startPoint: ConnectionEndPoint.none,
endPoint: ConnectionEndPoint.arrow,
),
// Temporary connection (while dragging)
temporaryConnectionTheme: ConnectionTheme(
color: Colors.grey.shade400,
strokeWidth: 2.0,
dashPattern: [5, 5], // Dashed line
),
// Port appearance
portTheme: PortTheme(
size: 10.0,
color: Colors.blue.shade400,
connectedColor: Colors.blue.shade700,
hoverColor: Colors.blue.shade800,
borderColor: Colors.white,
borderWidth: 2.0,
),
// Grid and canvas
backgroundColor: Colors.grey.shade50,
gridColor: Colors.grey.shade300,
gridSize: 20.0,
gridStyle: GridStyle.dots,
// Selection appearance
selectionColor: Colors.blue.withOpacity(0.2),
selectionBorderColor: Colors.blue,
selectionBorderWidth: 1.0,
);
controller.setTheme
(
customTheme
);
!NOTEGrid styles available:GridStyle.dots,GridStyle.lines,GridStyle.hierarchical,GridStyle.none
Building Nodes
Basic Node Widget
The simplest way to display nodes is using the default NodeWidget:
NodeFlowEditor<String>
(
controller: controller,
theme: theme,
nodeBuilder: (context, node) {
return NodeWidget.defaultStyle(node: node);
},
)
Custom Node Content
Create custom node content while keeping standard node functionality:
Custom Node Content Example
Widget _buildNode(BuildContext context, Node<Map<String, dynamic>> node) {
final data = node.data;
return Container(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Title
Text(
data['title'] ?? node.type,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Icon
if (data['icon'] != null)
Icon(data['icon'], size: 24, color: Colors.blue),
// Description
if (data['description'] != null)
Text(
data['description'],
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
);
}
Using Node Container Builder
For complete control over node appearance:
Custom Node Container Example
NodeFlowEditor<MyData>
(
controller: controller,
theme: theme,
nodeBuilder: (context, node) => _buildNodeContent(node),
nodeContainerBuilder: (context, node, content) {
// Return NodeWidget with custom styling
return NodeWidget<MyData>(
node: node,
child: content,
backgroundColor: _getNodeColor(node),
borderColor: node.isSelected ? Colors.blue : Colors.grey,
borderWidth: node.isSelected ? 3.0 : 1.0,
borderRadius: BorderRadius.circular(12),
);
},
)
Node Types and Data
Create strongly-typed nodes using sealed classes for type safety and pattern matching:
Sealed Class Node Data Example (Recommended)
// Define a sealed class hierarchy for all node types
sealed class NodeData {
const NodeData();
}
class SourceNodeData extends NodeData {
final String dataSource;
final String format;
const SourceNodeData({
required this.dataSource,
required this.format,
});
}
class ProcessNodeData extends NodeData {
final String title;
final String processType;
final IconData icon;
final Color color;
const ProcessNodeData({
required this.title,
required this.processType,
required this.icon,
required this.color,
});
}
class SinkNodeData extends NodeData {
final String destination;
final bool isActive;
const SinkNodeData({
required this.destination,
required this.isActive,
});
}
// Create nodes with typed data
final node = Node<NodeData>(
id: 'process-1',
type: 'process',
position: const Offset(100, 100),
data: ProcessNodeData(
title: 'Data Validation',
processType: 'validation',
icon: Icons.check_circle,
color: Colors.green,
),
inputPorts: [Port(id: 'in', name: 'Input')],
outputPorts: [Port(id: 'out', name: 'Output')],
);
// Use pattern matching in node builder
Widget _buildNode(BuildContext context, Node<NodeData> node) {
return switch (node.data) {
SourceNodeData(dataSource: final source, format: final format) =>
_buildSourceNode(source, format),
ProcessNodeData(:final title, :final icon, :final color) =>
Container(
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 8),
Text(title, style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
SinkNodeData(:final destination, :final isActive) =>
_buildSinkNode(destination, isActive),
};
}
Simple Class Node Data Example
For simpler use cases, you can use a single class:
class ProcessNodeData {
final String title;
final String processType;
final IconData icon;
final Color color;
ProcessNodeData({
required this.title,
required this.processType,
required this.icon,
required this.color,
});
}
// Create nodes with typed data
final node = Node<ProcessNodeData>(
id: 'process-1',
type: 'process',
position: const Offset(100, 100),
data: ProcessNodeData(
title: 'Data Validation',
processType: 'validation',
icon: Icons.check_circle,
color: Colors.green,
),
inputPorts: [Port(id: 'in', name: 'Input')],
outputPorts: [Port(id: 'out', name: 'Output')],
);
// Use in node builder
Widget _buildProcessNode(BuildContext context, Node<ProcessNodeData> node) {
final data = node.data;
return Container(
decoration: BoxDecoration(
color: data.color.withOpacity(0.1),
border: Border.all(color: data.color),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(data.icon, color: data.color),
const SizedBox(height: 8),
Text(data.title, style: TextStyle(fontWeight: FontWeight.bold)),
Text(data.processType, style: TextStyle(fontSize: 10)),
],
),
);
}
Working with Ports
Port Basics
Ports are connection points on nodes:
// Input port (left side)
const Port
(
id: 'input-1',
name: 'Input',
position: PortPosition.left,
type: PortType.target, // Can only receive connections
)
// Output port (right side)
const Port(
id: 'output-1',
name: 'Output',
position: PortPosition.right,
type: PortType.source, // Can only create connections
)
// Bidirectional port
const Port(
id: 'bidirectional',
name: 'Data',
type: PortType.
both
, // Can both send and receive
)
Port Positions and Offsets
Port Positioning Examples
// Left-side ports with vertical offsets
inputPorts: [
Port
(
id: 'in-1',
name: 'Input 1',
position: PortPosition.left,
offset: Offset(0, 20), // 20px from top
),
Port(
id: 'in-2',
name: 'Input 2',
position: PortPosition.left,
offset: Offset(0, 60), // 60px from top
),
]
// Top-side ports with horizontal offsets
inputPorts: [
Port(
id: 'in-1',
name: 'Input 1',
position: PortPosition.top,
offset: Offset(40, 0), // 40px from left
),
Port(
id: 'in-2',
name: 'Input 2',
position: PortPosition.top,
offset: Offset(120, 0
)
, // 120px from left
)
,
]
Port Shapes
const Port
(
id: 'port-1',
name: 'Data',
shape
:
PortShape
.
capsuleHalf
, // Default, oriented shape
)
// Available shapes:
// - PortShape.capsuleHalf (recommended, auto-oriented)
// - PortShape.circle
// - PortShape.square
// - PortShape.diamond
// - PortShape.triangle
Multiple Connections
Multi-Connection Port Example
// Allow unlimited connections
const Port
(
id: 'output',
name: 'Broadcast',
multiConnections: true,
)
// Limit to specific number
const Port(
id: 'output',
name: 'Limited',
multiConnections: true,
maxConnections: 3,
)
Dynamic Ports
Add or remove ports at runtime:
Dynamic Port Management
// Add a port
node.addOutputPort
(
Port(
id: 'new-output',
name: 'New Output',
position: PortPosition.right,
),
);
// Remove a port
node.removePort('port-id');
// Update a port
node.updatePort(
'port-id',
Port(
id: 'port-id',
name: 'Updated Name',
position: PortPosition.right,
),
);
// Find a port
final port = node.findPort(
'
port-id
'
);
Connections
Creating Connections
Users create connections by dragging from one port to another. You can also create them programmatically:
final connection = Connection(
id: 'conn-1',
sourceNodeId: 'node-1',
sourcePortId: 'output',
targetNodeId: 'node-2',
targetPortId: 'input',
);
controller.addConnection
(
connection
);
Connection Validation
Validate connections before they're created:
Connection Validation Example
NodeFlowEditor<MyData>
(
controller: controller,
theme: theme,
nodeBuilder: _buildNode,
// Validate when starting a connection
onBeforeStartConnection: (context) {
// Don't allow connections from disabled nodes
if (context.sourceNode.data.isDisabled) {
return ConnectionValidationResult.invalid(
reason: 'Cannot connect from disabled node',
);
}
return ConnectionValidationResult.valid();
},
// Validate when completing a connection
onBeforeCompleteConnection: (context) {
// Don't allow self-connections
if (context.sourceNode.id == context.targetNode.id) {
return ConnectionValidationResult.invalid(
reason: 'Cannot connect node to itself',
);
}
// Check for circular dependencies
if (_wouldCreateCycle(context)) {
return ConnectionValidationResult.invalid(
reason: 'Would create circular dependency',
);
}
// Check port compatibility
if (!_arePortsCompatible(context.sourcePort, context.targetPort)) {
return ConnectionValidationResult.invalid(
reason: 'Incompatible port types',
);
}
return ConnectionValidationResult.valid();
},
)
Connection Styles
Choose from multiple connection path styles:
// Smooth step (default, clean right-angle paths)
connectionStyle: ConnectionStyles.smoothstep
// Bezier curves (smooth, flowing curves)
connectionStyle: ConnectionStyles.bezier
// Straight lines (direct connections)
connectionStyle: ConnectionStyles.straight
// Step lines (right-angle paths)
connectionStyle: ConnectionStyles.step
Connection Endpoints
Customize connection line endpoints:
Connection Endpoint Styles
connectionTheme: ConnectionTheme
(
// Start endpoint (source port)
startPoint: ConnectionEndPoint.none,
// End endpoint (target port)
endPoint: ConnectionEndPoint.arrow,
// Other endpoint options:
// - ConnectionEndPoint.none
// - ConnectionEndPoint.arrow
// - ConnectionEndPoint.circle
// - ConnectionEndPoint.capsuleHalf (matches port shape)
)
Connection Labels
Add labels to connections:
Connection Label Example
final connection = Connection(
id: 'conn-1',
sourceNodeId: 'node-1',
sourcePortId: 'output',
targetNodeId: 'node-2',
targetPortId: 'input',
label: 'Data Flow', // Add label
);
// Customize label appearance in theme
labelTheme: LabelTheme
(
color: Colors.black87,
fontSize: 11.0,
fontWeight: FontWeight.w500,
backgroundColor: Colors.white,
borderColor: Colors.grey.shade300,
borderWidth: 1.0,
horizontalOffset: 8.0,
verticalOffset: 8.0,
)
Annotations
Annotations are floating elements that can be placed on the canvas for labels, notes, or custom visualizations.
Built-in Annotation Types
Sticky Note Annotation Example
// Add a sticky note annotation
controller.addAnnotation
(
StickyAnnotation(
id: 'note-1',
position: const Offset(100, 100),
text: 'This is a note',
width: 200,
height: 100,
color: Colors.yellow.shade100,
),
);
// Or use the convenience method
controller.createStickyNote(
position: const Offset(100, 100),
text: 'This is a note
'
,
);
Custom Annotations
Create your own annotation types:
Custom Annotation Implementation
class ImageAnnotation extends Annotation {
final String imageUrl;
final double width;
final double height;
ImageAnnotation({
required super.id,
required Offset position,
required this.imageUrl,
this.width = 200,
this.height = 150,
}) : super(
type: 'image',
initialPosition: position,
);
@override
Size get size => Size(width, height);
@override
Widget buildWidget(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(imageUrl, fit: BoxFit.cover),
),
);
}
@override
Map<String, dynamic> toJson() =>
{
'imageUrl': imageUrl,
'width': width,
'height': height,
};
@override
void fromJson(Map<String, dynamic> json) {
// Update from JSON if needed
}
}
// Use the custom annotation
controller.addAnnotation
(
ImageAnnotation(
id: 'img-1',
position: const Offset(200, 200),
imageUrl: 'https://example.com/image.jpg
'
,
)
,
);
Following Nodes
Make annotations follow nodes automatically:
Node-Following Annotation
final annotation = StickyAnnotation(
id: 'label-1',
position: const Offset(100, 100),
text: 'Important Node',
// This annotation will follow 'node-1'
dependencies: {'node-1'},
// Offset relative to the node
offset: const Offset(0, -50), // 50px above the node
);
controller.addAnnotation
(
annotation
);
Interactive Features
Editor Callbacks
All Available Callbacks
NodeFlowEditor<MyData>
(
controller: controller,
theme: theme,
nodeBuilder: _buildNode,
// Node events
onNodeSelected: (node) {
print('Node selected: ${node?.id}');
},
onNodeTap: (node) {
print('Node tapped: ${node.id}');
},
onNodeDoubleTap: (node) {
print('Node double-tapped: ${node.id}');
_showNodeEditor(node);
},
onNodeCreated: (node) {
print('Node created: ${node.id}');
},
onNodeDeleted: (node) {
print('Node deleted: ${node.id}');
},
// Connection events
onConnectionSelected: (connection) {
print('Connection selected: ${connection?.id}');
},
onConnectionTap: (connection) {
print('Connection tapped: ${connection.id}');
},
onConnectionDoubleTap: (connection) {
print('Connection double-tapped: ${connection.id}');
},
onConnectionCreated: (connection) {
print('Connection created: ${connection.id}');
_notifyConnectionChange();
},
onConnectionDeleted: (connection) {
print('Connection deleted: ${connection.id}');
},
// Connection validation callbacks
onBeforeStartConnection: (context) {
// Validate before starting a connection from a port
if (context.existingConnections.isNotEmpty &&
!context.sourcePort.multiConnections) {
return ConnectionValidationResult(
allowed: false,
message: 'Port already has a connection',
);
}
return ConnectionValidationResult.valid();
},
onBeforeCompleteConnection: (context) {
// Validate before completing a connection to a target port
if (_wouldCreateCycle(context.sourceNode, context.targetNode)) {
return ConnectionValidationResult(
allowed: false,
message: 'This would create a cycle',
);
}
return ConnectionValidationResult.valid();
},
// Annotation events
onAnnotationSelected: (annotation) {
print('Annotation selected: ${annotation?.id}');
},
onAnnotationTap: (annotation) {
print('Annotation tapped: ${annotation.id}');
},
onAnnotationCreated: (annotation) {
print('Annotation created: ${annotation.id}');
},
onAnnotationDeleted: (annotation) {
print('Annotation deleted: ${annotation.id}');
},
)
Keyboard Shortcuts
Built-in keyboard shortcuts are available:
Selection
| Shortcut | Action |
|---|---|
Cmd/Ctrl + A |
Select all nodes |
Cmd/Ctrl + I |
Invert selection |
Escape |
Clear selection/cancel |
Editing
| Shortcut | Action |
|---|---|
Delete / Backspace |
Delete selected nodes/connections |
Cmd/Ctrl + D |
Duplicate selected nodes |
N |
Toggle grid snapping |
Navigation
| Shortcut | Action |
|---|---|
F |
Fit all nodes to view |
H |
Fit selected to view |
Cmd/Ctrl + 0 |
Reset zoom to 100% |
Cmd/Ctrl + = |
Zoom in |
Cmd/Ctrl + - |
Zoom out |
M |
Toggle minimap |
Arrangement
| Shortcut | Action |
|---|---|
[ |
Send to back |
] |
Bring to front |
Cmd/Ctrl + [ |
Send backward one step |
Cmd/Ctrl + ] |
Bring forward one step |
Alignment (requires 2+ selected nodes)
| Shortcut | Action |
|---|---|
Cmd/Ctrl + Shift + ↑ |
Align top |
Cmd/Ctrl + Shift + ↓ |
Align bottom |
Cmd/Ctrl + Shift + ← |
Align left |
Cmd/Ctrl + Shift + → |
Align right |
Grouping
| Shortcut | Action |
|---|---|
Cmd/Ctrl + G |
Create group |
Cmd/Ctrl + Shift + G |
Ungroup |
Custom Keyboard Shortcuts
// Register custom actions
controller.shortcuts.registerAction
(
NodeFlowAction(
id: 'custom-action',
label: 'Custom Action',
shortcut: SingleActivator(
LogicalKeyboardKey.keyK,
control: true,
),
execute: (controller) {
// Your custom logic
print('Custom action executed!');
},
),
);
// Show shortcuts dialog
controller.showShortcutsDialog(context);
Feature Toggles
Control which interactions are enabled:
NodeFlowEditor<T>
(
controller: controller,
theme: theme,
nodeBuilder: _buildNode,
enablePanning: true, // Pan with space+drag or right-click
enableZooming: true, // Zoom with mouse wheel
enableSelection: true, // Select nodes and connections
enableNodeDragging: true, // Drag nodes to reposition
enableConnectionCreation: true, // Create connections by dragging
scrollToZoom: true, // Zoom with trackpad scroll
showAnnotations: true, // Display annotation layer
)
Minimap
Enable the built-in minimap for easier navigation in large graphs:
// Configure minimap in the controller
final controller = NodeFlowController<T>(
config: NodeFlowConfig(
showMinimap: true, // Enable minimap
isMinimapInteractive: true, // Allow click-to-navigate
minimapPosition: MinimapPosition.bottomRight, // Position on screen
minimapSize: const Size(200, 150), // Minimap dimensions
),
);
// Use with editor - minimap appears automatically
NodeFlowEditor<T>
(
controller: controller,
theme: theme,
nodeBuilder: _buildNode,
);
You can also toggle the minimap at runtime:
// Toggle minimap visibility
controller.config.toggleMinimap
();
// Change minimap position
controller.config.setMinimapPosition
(
MinimapPosition
.
topLeft
);
!TIPThe minimap automatically updates as you pan, zoom, and modify the graph. Available positions:topLeft,topRight,bottomLeft,bottomRight.
Read-Only Viewer
Display flows without editing capabilities:
NodeFlowViewer<T>
(
controller: controller,
theme: theme,
nodeBuilder: _buildNode,
enablePanning: true,
enableZooming: true,
scrollToZoom:
true
,
);
The viewer supports panning and zooming but prevents editing, making it perfect for displaying workflows, process diagrams, or results.
Serialization
Save and Load Graphs
Complete Serialization Example
// Export graph to JSON
final graph = controller.exportGraph();
final json = graph.toJson((data) => data.toJson());
final jsonString = jsonEncode(json);
// Save to file
await File
('my_flow.json
'
)
.writeAsString(jsonString);
// Load from file
final loadedJson = await File('my_flow.json').readAsString();
final decoded = jsonDecode(loadedJson);
// Import graph
final loadedGraph = NodeGraph.fromJson(
decoded,
(json) => MyData.fromJson(json),
);
controller.
loadGraph
(
loadedGraph
);
Load from URL
// Load graph from a URL (web/assets)
final graph = await
NodeGraph.fromUrl<MyData>
('assets/workflows/my_flow.json
'
,
);
controller.
loadGraph
(
graph
);
🛠️ Advanced Configuration
Grid Snapping
Grid Configuration
final config = NodeFlowConfig(
snapToGrid: true, // Snap nodes to grid
snapAnnotationsToGrid: true, // Snap annotations to grid
gridSize: 20.0, // Grid cell size
portSnapDistance: 15.0, // Distance for port snapping
);
// Toggle snapping at runtime
config.toggleSnapping
();config.toggleNodeSnapping
();config.toggleAnnotationSnapping
();
Auto-Panning
Auto-Pan Configuration
final config = NodeFlowConfig(
autoPanMargin: 50.0, // Pixels from edge to trigger auto-pan
autoPanSpeed: 0.3, // Pan speed (0.0 to 1.0)
);
When dragging nodes or connections near the canvas edge, the viewport automatically pans.
Zoom Limits
final config = NodeFlowConfig(
minZoom: 0.25, // Minimum zoom level (25%)
maxZoom: 3.0, // Maximum zoom level (300%)
);
Complete Examples
Example 1: Simple Data Pipeline
View Code
class DataPipelineEditor extends StatefulWidget {
@override
State<DataPipelineEditor> createState() => _DataPipelineEditorState();
}
class _DataPipelineEditorState extends State<DataPipelineEditor> {
late final NodeFlowController<String> controller;
@override
void initState() {
super.initState();
controller = NodeFlowController<String>();
controller.setTheme(NodeFlowTheme.light);
_createPipeline();
}
void _createPipeline() {
// Source node
controller.addNode(Node<String>(
id: 'source',
type: 'source',
position: const Offset(100, 200),
data: 'Data Source',
outputPorts: const [
Port(id: 'out', name: 'Output', position: PortPosition.right),
],
));
// Transform node
controller.addNode(Node<String>(
id: 'transform',
type: 'transform',
position: const Offset(350, 200),
data: 'Transform',
inputPorts: const [
Port(id: 'in', name: 'Input', position: PortPosition.left),
],
outputPorts: const [
Port(id: 'out', name: 'Output', position: PortPosition.right),
],
));
// Sink node
controller.addNode(Node<String>(
id: 'sink',
type: 'sink',
position: const Offset(600, 200),
data: 'Data Sink',
inputPorts: const [
Port(id: 'in', name: 'Input', position: PortPosition.left),
],
));
// Create connections
controller.addConnection(Connection(
id: 'c1',
sourceNodeId: 'source',
sourcePortId: 'out',
targetNodeId: 'transform',
targetPortId: 'in',
));
controller.addConnection(Connection(
id: 'c2',
sourceNodeId: 'transform',
sourcePortId: 'out',
targetNodeId: 'sink',
targetPortId: 'in',
));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Data Pipeline')),
body: NodeFlowEditor<String>(
controller: controller,
theme: NodeFlowTheme.light,
nodeBuilder: _buildNode,
),
);
}
Widget _buildNode(BuildContext context, Node<String> node) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_getIcon(node.type), size: 24),
const SizedBox(height: 8),
Text(node.data, style: TextStyle(fontWeight: FontWeight.bold)),
],
),
);
}
IconData _getIcon(String type) {
switch (type) {
case 'source':
return Icons.input;
case 'transform':
return Icons.transform;
case 'sink':
return Icons.output;
default:
return Icons.circle;
}
}
}
Example 2: Workflow Builder with Validation
View Code
class WorkflowBuilder extends StatefulWidget {
@override
State<WorkflowBuilder> createState() => _WorkflowBuilderState();
}
class _WorkflowBuilderState extends State<WorkflowBuilder> {
late final NodeFlowController<WorkflowNodeData> controller;
@override
void initState() {
super.initState();
controller = NodeFlowController<WorkflowNodeData>();
controller.setTheme(_createWorkflowTheme());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Workflow Builder'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _showAddNodeDialog,
tooltip: 'Add Node',
),
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveWorkflow,
tooltip: 'Save',
),
],
),
body: NodeFlowEditor<WorkflowNodeData>(
controller: controller,
theme: _createWorkflowTheme(),
nodeBuilder: _buildWorkflowNode,
// Prevent invalid connections
onBeforeCompleteConnection: (context) {
// Don't allow loops
if (context.sourceNode.id == context.targetNode.id) {
return ConnectionValidationResult.invalid(
reason: 'Cannot connect to self',
);
}
// Check for cycles
if (_wouldCreateCycle(context)) {
return ConnectionValidationResult.invalid(
reason: 'Would create circular dependency',
);
}
return ConnectionValidationResult.valid();
},
onConnectionCreated: (connection) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Connection created')),
);
},
),
);
}
Widget _buildWorkflowNode(BuildContext context, Node<WorkflowNodeData> node) {
final data = node.data;
return Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(data.icon, size: 20, color: data.color),
const SizedBox(width: 8),
Text(
data.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
if (data.description != null) ...[
const SizedBox(height: 8),
Text(
data.description!,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
textAlign: TextAlign.center,
),
],
],
),
);
}
bool _wouldCreateCycle(ConnectionCompleteContext context) {
// Implement cycle detection logic
// This is a simplified version
return false;
}
void _showAddNodeDialog() {
// Show dialog to add new nodes
}
Future<void> _saveWorkflow() async {
final graph = controller.exportGraph();
// Save to file or database
}
NodeFlowTheme _createWorkflowTheme() {
return NodeFlowTheme.light.copyWith(
connectionStyle: ConnectionStyles.smoothstep,
gridStyle: GridStyle.dots,
);
}
}
class WorkflowNodeData {
final String title;
final String? description;
final IconData icon;
final Color color;
WorkflowNodeData({
required this.title,
this.description,
required this.icon,
required this.color,
});
}
Example 3: Read-Only Process Viewer
View Code
class ProcessViewer extends StatelessWidget {
final String processJsonPath;
const ProcessViewer({required this.processJsonPath});
@override
Widget build(BuildContext context) {
return FutureBuilder<NodeGraph<String>>(
future: NodeGraph.fromUrl(processJsonPath),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final controller = NodeFlowController<String>();
controller.setTheme(NodeFlowTheme.light);
controller.loadGraph(snapshot.data!);
return Scaffold(
appBar: AppBar(
title: const Text('Process View'),
actions: [
IconButton(
icon: const Icon(Icons.zoom_out),
onPressed: () => controller.zoomBy(-0.1),
),
IconButton(
icon: const Icon(Icons.zoom_in),
onPressed: () => controller.zoomBy(0.1),
),
IconButton(
icon: const Icon(Icons.fit_screen),
onPressed: () => controller.fitToView(),
),
],
),
body: Stack(
children: [
NodeFlowViewer<String>(
controller: controller,
theme: NodeFlowTheme.light,
nodeBuilder: _buildNode,
),
// Legend
Positioned(
top: 16,
left: 16,
child: _buildLegend(),
),
],
),
);
},
);
}
Widget _buildNode(BuildContext context, Node<String> node) {
return Container(
padding: const EdgeInsets.all(12),
child: Text(node.data),
);
}
Widget _buildLegend() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Legend', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_legendItem(Colors.green, 'Start'),
_legendItem(Colors.blue, 'Process'),
_legendItem(Colors.orange, 'Decision'),
_legendItem(Colors.red, 'End'),
],
),
);
}
Widget _legendItem(Color color, String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontSize: 12)),
],
),
);
}
}
🔍 API Reference
NodeFlowController
| Method | Description |
|---|---|
addNode(Node node) |
Add a node to the graph |
removeNode(String id) |
Remove a node by ID |
getNode(String id) |
Get a node by ID |
setNodePosition(String id, Offset position) |
Set node position |
addConnection(Connection conn) |
Add a connection |
removeConnection(String id) |
Remove a connection |
getConnectionsForNode(String id) |
Get connections for a node |
selectNode(String id) |
Select a node |
clearSelection() |
Clear all selections |
setViewport(GraphViewport) |
Set viewport position and zoom |
zoomBy(double delta) |
Adjust zoom by delta |
zoomTo(double zoom) |
Set specific zoom level |
fitToView() |
Fit all nodes in view |
centerOnNode(String id) |
Center viewport on node |
exportGraph() |
Export graph to JSON |
loadGraph(NodeGraph) |
Load graph from data |
clearGraph() |
Clear all nodes and connections |
Node
| Property | Type | Description |
|---|---|---|
id |
String | Unique identifier |
type |
String | Node type label |
data |
T | Custom node data |
position |
Offset | Node position |
size |
Size | Node dimensions |
inputPorts |
List | Input ports |
outputPorts |
List | Output ports |
isSelected |
bool | Selection state |
Port
| Property | Type | Description |
|---|---|---|
id |
String | Unique identifier |
name |
String | Display name |
position |
PortPosition | Port location on node |
offset |
Offset | Position offset |
type |
PortType | source/target/both |
shape |
PortShape | Visual appearance |
multiConnections |
bool | Allow multiple connections |
maxConnections |
int? | Connection limit |
Connection
| Property | Type | Description |
|---|---|---|
id |
String | Unique identifier |
sourceNodeId |
String | Source node ID |
sourcePortId |
String | Source port ID |
targetNodeId |
String | Target node ID |
targetPortId |
String | Target port ID |
label |
String? | Connection label |
💡 Tips and Best Practices
!TIPPerformance: Use specific data types forNode<T>rather thanMap<String, dynamic>when possible for better type safety and performance.
!WARNINGLarge Graphs: For graphs with 100+ nodes, consider implementing virtualization or chunking strategies. The minimap helps with navigation.
!NOTESerialization: When implementing custom node data types, ensure they have propertoJson()andfromJson()methods for serialization.
Acknowledgments
Inspired by React Flow - a powerful node-based editor for React applications.
Made with ❤️ by the Vyuh Team
Libraries
- annotations/annotation
- annotations/annotation_layer
- annotations/annotation_widget
- connections/connection
- connections/connection_endpoint
- connections/connection_painter
- connections/connection_path_cache
- connections/connection_path_calculator
- connections/connection_style_base
- connections/connection_styles
- connections/connection_theme
- connections/connection_validation
- connections/connections_canvas
- connections/edge_label_position_calculator
- connections/endpoint_painter
- connections/endpoint_position_calculator
- connections/label_theme
- connections/smoothstep_path_calculator
- connections/styles/bezier_connection_style
- connections/styles/step_connection_style
- connections/styles/straight_connection_style
- connections/temporary_connection
- graph/graph
- graph/grid_painter
- graph/layers/connection_labels_layer
- graph/layers/connections_layer
- graph/layers/grid_layer
- graph/layers/interaction_layer
- graph/layers/minimap_overlay
- graph/layers/nodes_layer
- graph/node_flow_callbacks
- graph/node_flow_config
- graph/node_flow_controller
- graph/node_flow_editor
- graph/node_flow_layout_delegate
- graph/node_flow_minimap
- graph/node_flow_theme
- graph/node_flow_viewer
- graph/selection_painter
- graph/viewport
- models/node_data
- nodes/interaction_state
- nodes/node
- nodes/node_theme
- nodes/node_widget
- ports/capsule_half
- ports/point_shape_painter
- ports/port
- ports/port_shape_widget
- ports/port_theme
- ports/port_widget
- shared/flutter_actions_integration
- shared/grid_calculator
- shared/json_converters
- shared/label_position_calculator
- shared/node_flow_actions
- shared/node_spatial_adapter
- shared/spatial
- Ultra-fast spatial indexing system for 2D objects
- shared/spatial_index
- vyuh_node_flow
- widgets/shortcuts_viewer_dialog