BetaTry our live BPMN Workflow EditorSkip to content

Nodes

Nodes are the fundamental building blocks of your flow graph. They represent entities in your visual programming interface, workflow, or diagram.

Node Structure

A Node<T> is a generic class where T can be any type you choose for your custom data:

dart
class Node<T> {
  final String id;                        // Unique identifier
  final String type;                      // Node type for categorization
  final Observable<Offset> position;      // Position on canvas
  final Observable<Offset> visualPosition; // Visual position (may include snap-to-grid)
  final Observable<Size> size;            // Dimensions
  final T data;                           // Your custom data (any type)
  final ObservableList<Port> inputPorts;  // Input connection points
  final ObservableList<Port> outputPorts; // Output connection points
  final Observable<int> zIndex;           // Layer order
  final Observable<bool> selected;        // Selection state
  final Observable<bool> dragging;        // Dragging state
  final NodeRenderLayer layer;            // Rendering layer (background/middle/foreground)
  final bool locked;                      // Prevent user dragging (programmatic moves still work)
  final bool selectable;                  // Whether node participates in marquee selection
  bool isVisible;                         // Show/hide node (getter/setter)
  bool isResizable;                       // Whether node can be resized (getter)
}

Node Render Layers

Nodes are rendered in three layers:

dart
enum NodeRenderLayer {
  background,  // Behind regular nodes (e.g., group annotations)
  middle,      // Default layer for regular nodes
  foreground,  // Above nodes and connections (e.g., sticky notes)
}

Node Anatomy

Node Anatomy Diagram

A node consists of the following visual elements:

Container Elements:

  • Node Background - The filled area using NodeTheme.backgroundColor
  • Node Border - The outline using NodeTheme.borderColor and borderWidth
  • Node Shape - Optional custom shape (Circle, Diamond, Hexagon, etc.)
  • Selection Highlight - Visual feedback when selected using NodeTheme.selectedBackgroundColor
  • Drag Shadow - Shadow effect during drag operations

Content Elements:

  • Title Area - Header section styled with NodeTheme.titleStyle
  • Content Area - Main body styled with NodeTheme.contentStyle
  • Custom Widget - Your nodeBuilder content

Interaction Elements:

  • Resize Handles - Appear on resizable nodes (GroupNode, CommentNode)
  • Ports - Connection points on node edges (see Ports)

State Indicators:

  • Hover State - Visual feedback using NodeTheme.highlightBackgroundColor
  • Locked State - Visual indicator when node.locked = true
  • Editing State - Special mode for inline editing (CommentNode)

Creating Nodes

dart
final node = Node<ProcessData>(
  id: 'node-1',
  type: 'process',
  position: const Offset(100, 100),
  size: const Size(200, 100),
  data: ProcessData(title: 'Process Step'),
  inputPorts: [
    Port(
      id: 'input-1',
      name: 'Input',
      position: PortPosition.left,
      // type is inferred as PortType.input for left/top positions
    ),
  ],
  outputPorts: [
    Port(
      id: 'output-1',
      name: 'Output',
      position: PortPosition.right,
      // type is inferred as PortType.output for right/bottom positions
    ),
  ],
);
dart
final conditionalNode = Node<ProcessData>(
  id: 'condition-1',
  type: 'condition',
  position: const Offset(300, 100),
  size: const Size(180, 120),
  data: ProcessData(title: 'If/Else'),
  inputPorts: [
    Port(
      id: 'cond-input',
      name: 'Input',
      position: PortPosition.left,
    ),
  ],
  outputPorts: [
    Port(
      id: 'true-output',
      name: 'True',
      position: PortPosition.right,
      offset: const Offset(0, 33),  // 1/3 of node height
    ),
    Port(
      id: 'false-output',
      name: 'False',
      position: PortPosition.right,
      offset: const Offset(0, 67),  // 2/3 of node height
    ),
  ],
);

Custom Node Data

The generic type T in Node<T> can be any type - a class, record, primitive, or even void:

dart
// Simple class for node data
class ProcessData {
  final String title;
  final String description;
  final Map<String, dynamic> config;

  const ProcessData({
    required this.title,
    this.description = '',
    this.config = const {},
  });
}

// Use with nodes
final node = Node<ProcessData>(
  id: 'node-1',
  type: 'process',
  position: const Offset(100, 100),
  size: const Size(200, 100),
  data: ProcessData(title: 'Process Step'),
  // ...
);

For serialization, provide conversion functions:

dart
// Export
final json = controller.toJson(
  (data) => {
    'title': data.title,
    'description': data.description,
    'config': data.config,
  },
);

// Import
controller.fromJson(
  json,
  dataFromJson: (json) => ProcessData(
    title: json['title'] as String,
    description: json['description'] as String? ?? '',
    config: Map<String, dynamic>.from(json['config'] ?? {}),
  ),
);

Node Types

Use the type field to categorize nodes:

dart
enum NodeType {
  start,
  process,
  condition,
  end,
}

// Create typed nodes
final startNode = Node<MyData>(
  type: NodeType.start.name,
  // ...
);

final processNode = Node<MyData>(
  type: NodeType.process.name,
  // ...
);

Benefits:

  • Different visual styles based on type
  • Type-specific validation rules
  • Easy filtering and querying
Node Types Visualization

Four node types shown with distinct visual treatments: Start node (rounded/circular, green), Process node (rectangular, blue), Condition node (diamond shape, yellow with True/False outputs), End node (rounded/circular, red). Each shows appropriate port configurations.

Specialized Node Types

Vyuh Node Flow provides two specialized node types that extend the base Node class with additional capabilities:

GroupNode

GroupNode creates visual regions for containing other nodes. It includes both ResizableMixin and GroupableMixin.

dart
final groupNode = GroupNode<String>(
  id: 'group-1',
  position: const Offset(100, 100),
  size: const Size(400, 300),
  title: 'Processing Region',
  data: 'group-data',
  color: Colors.blue,
  behavior: GroupBehavior.bounds,  // or .explicit, .parent
);

Group behaviors:

  • bounds: Spatial containment - nodes inside the bounds move with the group
  • explicit: Auto-sizes to fit explicitly added member nodes
  • parent: Parent-child link - nodes move with group but can be positioned outside

CommentNode

CommentNode creates sticky note-style annotations. It includes ResizableMixin.

dart
final comment = CommentNode<String>(
  id: 'note-1',
  position: const Offset(100, 100),
  text: 'This is a reminder',
  data: 'optional-data',
  width: 200,
  height: 150,
  color: Colors.yellow,
);

Features:

  • Inline text editing (double-click to edit)
  • Auto-grow height when text exceeds bounds
  • Renders in foreground layer (above regular nodes)
  • Does not participate in marquee selection

Node Positioning

dart
// Direct observable access
node.position.value = const Offset(100, 200);

// Or use controller method (respects snap-to-grid)
controller.setNodePosition('node-1', const Offset(100, 200));
dart
// Move right by 50 pixels using controller
controller.moveNode('node-1', const Offset(50, 0));

// Or directly via observable
final currentPos = node.position.value;
node.position.value = currentPos + const Offset(50, 0);
dart
// Move all selected nodes together
controller.moveSelectedNodes(const Offset(50, 0));

Z-Index and Layering

Control which nodes appear on top:

dart
// Bring node to front (renders on top of all other nodes)
controller.bringNodeToFront('node-1');

// Send to back (renders behind all other nodes)
controller.sendNodeToBack('node-1');

// Incremental positioning
controller.bringNodeForward('node-1');  // Move one step forward
controller.sendNodeBackward('node-1');  // Move one step backward

// Direct observable access
node.zIndex.value = 10;

Node Widget Rendering

There are two approaches for rendering node content:

Using nodeBuilder

Provide a custom widget builder to render nodes based on their type:

dart
NodeFlowEditor<MyData, dynamic>(
  controller: controller,
  nodeBuilder: (context, node) {
    switch (node.type) {
      case 'start':
        return StartNodeWidget(node: node);
      case 'process':
        return ProcessNodeWidget(node: node);
      case 'condition':
        return ConditionNodeWidget(node: node);
      default:
        return DefaultNodeWidget(node: node);
    }
  },
)

Self-Rendering Nodes

Nodes can override buildWidget() to control their own rendering. This is used by specialized nodes like GroupNode and CommentNode:

dart
class MyCustomNode<T> extends Node<T> {
  @override
  Widget? buildWidget(BuildContext context) {
    return Container(
      // Custom widget implementation
    );
  }
}

When buildWidget() returns non-null, the nodeBuilder callback is not used for that node.

Using NodeWidget

For standard node styling, use the NodeWidget class:

dart
NodeWidget<MyData>(
  node: node,
  theme: nodeTheme,
  child: MyCustomContent(data: node.data),
  backgroundColor: Colors.blue.shade50,
)

// Or use default styling
NodeWidget<MyData>.defaultStyle(
  node: node,
  theme: nodeTheme,
)

Example Node Widget

dart
class ProcessNodeWidget extends StatelessWidget {
  final Node<ProcessNodeData> node;

  const ProcessNodeWidget({required this.node});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: node.size.value.width,
      height: node.size.value.height,
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.blue, width: 2),
        boxShadow: const [
          BoxShadow(
            color: Colors.black26,
            blurRadius: 8,
            offset: Offset(0, 4),
          ),
        ],
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.settings, size: 32, color: Colors.blue),
          const SizedBox(height: 8),
          Text(
            node.data.title,
            style: const TextStyle(
              fontWeight: FontWeight.bold,
              fontSize: 14,
            ),
          ),
          if (node.data.description.isNotEmpty)
            Text(
              node.data.description,
              style: TextStyle(
                fontSize: 10,
                color: Colors.grey[600],
              ),
            ),
        ],
      ),
    );
  }
}

Node Selection

dart
controller.selectNode('node-1');
dart
controller.selectNode('node-1', toggle: true);
controller.selectNode('node-2', toggle: true);

// Or select multiple at once
controller.selectNodes(['node-1', 'node-2', 'node-3']);
controller.selectNodes(['node-4', 'node-5'], toggle: true);
dart
controller.clearNodeSelection();
dart
// Check if a specific node is selected
final isSelected = controller.isNodeSelected('node-1');

// Get all selected node IDs
final selectedIds = controller.selectedNodeIds;
final selectedNodes = selectedIds
    .map((id) => controller.getNode(id))
    .whereType<Node<MyData>>()
    .toList();

Node Operations

dart
controller.addNode(node);
dart
// Direct removal (skips lock checks and callbacks)
controller.removeNode('node-1');

// Request deletion (respects locks and onBeforeDelete callback)
final deleted = await controller.requestDeleteNode('node-1');
if (!deleted) {
  print('Node deletion was prevented');
}

// Delete multiple nodes
controller.deleteNodes(['node-1', 'node-2', 'node-3']);
dart
// Creates a copy with new ID, offset by 50px
controller.duplicateNode('node-1');
dart
final node = controller.getNode('node-1');
if (node != null) {
  node.position.value = const Offset(200, 200);
  // Node data is mutable if needed
}

// Or use controller methods
controller.setNodePosition('node-1', const Offset(200, 200));
controller.setNodeSize('node-1', const Size(150, 100));
dart
// Get node by ID
final node = controller.getNode('node-1');

// Get all node IDs
final allNodeIds = controller.nodeIds;
final count = controller.nodeCount;

// Get nodes by type
final processNodes = controller.getNodesByType('process');

// Get visible/hidden nodes
final visibleNodes = controller.getVisibleNodes();
final hiddenNodes = controller.getHiddenNodes();

// Get node bounds
final bounds = controller.getNodeBounds('node-1');

Interactive Nodes

Make nodes respond to interactions:

dart
class InteractiveNodeWidget extends StatelessWidget {
  final Node<MyData> node;
  final VoidCallback onTap;

  const InteractiveNodeWidget({
    required this.node,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        // Node UI
        child: Text(node.data.title),
      ),
    );
  }
}

// Usage in node builder
nodeBuilder: (context, node) {
  return InteractiveNodeWidget(
    node: node,
    onTap: () {
      // Handle tap
      showDialog(
        context: context,
        builder: (_) => NodePropertiesDialog(node: node),
      );
    },
  );
}

Node Visibility

Control node visibility programmatically:

dart
// Set visibility for a single node
controller.setNodeVisibility('node-1', false);  // Hide
controller.setNodeVisibility('node-1', true);   // Show

// Toggle visibility
final newVisibility = controller.toggleNodeVisibility('node-1');

// Bulk operations
controller.setNodesVisibility(['node-1', 'node-2'], false);
controller.hideAllNodes();
controller.showAllNodes();
controller.hideSelectedNodes();
controller.showSelectedNodes();

Node Alignment and Distribution

Align and distribute multiple nodes:

dart
// Align nodes (requires at least 2 nodes)
controller.alignNodes(['node-1', 'node-2', 'node-3'], NodeAlignment.left);
controller.alignNodes(['node-1', 'node-2', 'node-3'], NodeAlignment.center);

// Distribute nodes evenly (requires at least 3 nodes)
controller.distributeNodesHorizontally(['node-1', 'node-2', 'node-3']);
controller.distributeNodesVertically(['node-1', 'node-2', 'node-3']);

Available alignments: left, right, top, bottom, center, horizontalCenter, verticalCenter

Best Practices

  1. Unique IDs: Always use unique, meaningful IDs
  2. Type Naming: Use consistent type naming convention
  3. Data Immutability: Consider implementing NodeData interface for cloneable data
  4. Size Consistency: Keep similar node types at similar sizes
  5. Port Placement: Place ports logically for flow direction
  6. Z-Index: Use controller methods like bringNodeToFront() instead of direct manipulation
  7. Widget Performance: Keep node widgets lightweight
  8. Use Observables Reactively: Access .value inside Observer widgets for reactive updates

Common Patterns

Factory Pattern for Nodes

dart
class NodeFactory {
  static Node<MyData> createStartNode(Offset position) {
    return Node<MyData>(
      id: 'start-${DateTime.now().millisecondsSinceEpoch}',
      type: 'start',
      position: position,
      size: const Size(100, 60),
      data: MyData(title: 'Start'),
      outputPorts: [
        Port(
          id: 'start-out',
          name: 'Output',
          position: PortPosition.right,
        ),
      ],
    );
  }

  static Node<MyData> createProcessNode(Offset position, String title) {
    return Node<MyData>(
      id: 'process-${DateTime.now().millisecondsSinceEpoch}',
      type: 'process',
      position: position,
      size: const Size(150, 80),
      data: MyData(title: title),
      inputPorts: [
        Port(id: 'in-1', name: 'Input', position: PortPosition.left),
      ],
      outputPorts: [
        Port(id: 'out-1', name: 'Output', position: PortPosition.right),
      ],
    );
  }
}

Port Management

Nodes support dynamic port management:

dart
final node = controller.getNode('node-1');
if (node != null) {
  // Add ports
  node.addInputPort(Port(id: 'new-in', name: 'New Input', position: PortPosition.left));
  node.addOutputPort(Port(id: 'new-out', name: 'New Output', position: PortPosition.right));

  // Remove ports
  node.removeInputPort('input-id');
  node.removeOutputPort('output-id');
  node.removePort('any-port-id');  // Searches both input and output

  // Update ports
  node.updateInputPort('input-id', updatedPort);
  node.updateOutputPort('output-id', updatedPort);

  // Find ports
  final port = node.findPort('port-id');
  final allPorts = node.allPorts;
}

// Or use controller methods
controller.addInputPort('node-1', port);
controller.addOutputPort('node-1', port);
controller.removePort('node-1', 'port-id');
controller.setNodePorts('node-1', inputPorts: [...], outputPorts: [...]);

Next Steps