Vyuh Node Flow

Vyuh Node Flow Banner

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.

Pub Version License

Try the Demo

👉 Launch 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

Node Flow Editor Screenshot 1

Node Flow Editor Screenshot 2

Animation of NodeFlowEditor

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),
);

!TIP The 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
);

!NOTE Grid 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
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
);

!TIP The 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

!TIP Performance: Use specific data types for Node<T> rather than Map<String, dynamic> when possible for better type safety and performance.

!WARNING Large Graphs: For graphs with 100+ nodes, consider implementing virtualization or chunking strategies. The minimap helps with navigation.

!NOTE Serialization: When implementing custom node data types, ensure they have proper toJson() and fromJson() 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