vyuh_node_flow 0.2.7 copy "vyuh_node_flow: ^0.2.7" to clipboard
vyuh_node_flow: ^0.2.7 copied to clipboard

A flexible, high-performance node-based flow editor for Flutter. Build visual programming interfaces, workflow editors, diagrams, and data pipelines.

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

43
likes
160
points
72
downloads
screenshot

Publisher

verified publishervyuh.tech

Weekly Downloads

A flexible, high-performance node-based flow editor for Flutter. Build visual programming interfaces, workflow editors, diagrams, and data pipelines.

Homepage
Repository (GitHub)
View/report issues

Topics

#node-editor #visual-programming #graph-editor #workflow #diagram

Documentation

API reference

License

MIT (license)

Dependencies

collection, equatable, flutter, flutter_mobx, http, json_annotation, mobx, vector_math

More

Packages that depend on vyuh_node_flow