BetaTry our live BPMN Workflow EditorSkip to content

Node Widget

🖼️ Node Widget Examples

Gallery of different node widget designs: Start node (green circular with play icon), Process node (rectangular with title, description, and settings icon), Condition node (diamond-shaped with question mark), End node (red circular with stop icon). Each showing ports, selection states, and hover effects.

The Node Widget is what users see and interact with in your flow editor. You have complete control over how nodes appear through the nodeBuilder function.

Basic Node Widget

The simplest node widget is just a container with some text:

dart
NodeFlowEditor<String, dynamic>(
  controller: controller,
  nodeBuilder: (context, node) {
    return Container(
      padding: EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.blue),
      ),
      child: Text(node.data),
    );
  },
)

Widget Structure

A typical node widget has these parts:

  1. Container: Defines size, decoration, borders
  2. Content: Title, icon, description, data
  3. Interactivity: Gesture handlers for taps, double-taps
  4. State Indicators: Selection, hover, disabled states
dart
Widget buildNodeWidget(BuildContext context, Node<MyData> node) {
  final size = node.size.value; // Access Observable<Size> value
  final isSelected = node.isSelected;

  return Container(
    width: size.width,
    height: size.height,
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(8),
      border: Border.all(
        color: isSelected ? Colors.blue : Colors.grey[300]!,
        width: isSelected ? 2 : 1,
      ),
      boxShadow: [
        BoxShadow(
          color: Colors.black12,
          blurRadius: 8,
          offset: Offset(0, 4),
        ),
      ],
    ),
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(getIconForType(node.type)),
        SizedBox(height: 8),
        Text(
          node.data.title,
          style: TextStyle(fontWeight: FontWeight.bold),
        ),
        if (node.data.description.isNotEmpty)
          Text(
            node.data.description,
            style: TextStyle(fontSize: 10, color: Colors.grey),
          ),
      ],
    ),
  );
}

Type-Based Widgets

Use the node type field to render different widgets:

dart
nodeBuilder: (context, node) {
  switch (node.type) {
    case 'start':
      return StartNodeWidget(node: node);
    case 'process':
      return ProcessNodeWidget(node: node);
    case 'condition':
      return ConditionNodeWidget(node: node);
    case 'end':
      return EndNodeWidget(node: node);
    default:
      return DefaultNodeWidget(node: node);
  }
}
dart
class StartNodeWidget extends StatelessWidget {
  final Node<MyData> node;

  const StartNodeWidget({required this.node});

  @override
  Widget build(BuildContext context) {
    final size = node.size.value;

    return Container(
      width: size.width,
      height: size.height,
      decoration: BoxDecoration(
        color: Colors.green[50],
        borderRadius: BorderRadius.circular(24),
        border: Border.all(color: Colors.green, width: 2),
      ),
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.play_arrow, color: Colors.green, size: 32),
            SizedBox(height: 4),
            Text(
              'START',
              style: TextStyle(
                color: Colors.green[700],
                fontWeight: FontWeight.bold,
                fontSize: 12,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
dart
class ProcessNodeWidget extends StatelessWidget {
  final Node<ProcessData> 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: [
          BoxShadow(
            color: Colors.blue.withValues(alpha: 0.1),
            blurRadius: 8,
            offset: Offset(0, 4),
          ),
        ],
      ),
      child: Padding(
        padding: EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(Icons.settings, size: 20, color: Colors.blue),
                SizedBox(width: 8),
                Expanded(
                  child: Text(
                    node.data.title,
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      fontSize: 14,
                    ),
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ],
            ),
            if (node.data.description.isNotEmpty) ...[
              SizedBox(height: 8),
              Text(
                node.data.description,
                style: TextStyle(
                  fontSize: 11,
                  color: Colors.grey[600],
                ),
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
              ),
            ],
          ],
        ),
      ),
    );
  }
}
dart
class ConditionNodeWidget extends StatelessWidget {
  final Node<ConditionData> node;

  const ConditionNodeWidget({required this.node});

  @override
  Widget build(BuildContext context) {
    final size = node.size.value;

    return Container(
      width: size.width,
      height: size.height,
      decoration: BoxDecoration(
        color: Colors.amber[50],
        border: Border.all(color: Colors.amber, width: 2),
      ),
      child: Stack(
        children: [
          // Diamond shape using CustomPaint
          CustomPaint(
            painter: DiamondPainter(color: Colors.amber[50]!),
            child: Container(),
          ),
          Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.help_outline, color: Colors.amber[900]),
                SizedBox(height: 4),
                Text(
                  node.data.condition,
                  style: TextStyle(
                    fontSize: 11,
                    fontWeight: FontWeight.bold,
                  ),
                  textAlign: TextAlign.center,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Interactive Nodes

Add interactivity to your nodes:

dart
class InteractiveNodeWidget extends StatelessWidget {
  final Node<MyData> node;
  final NodeFlowController controller;

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => _handleTap(context),
      onDoubleTap: () => _handleDoubleTap(context),
      onLongPress: () => _handleLongPress(context),
      child: Container(
        // Node UI
        child: Text(node.data.title),
      ),
    );
  }

  void _handleTap(BuildContext context) {
    controller.selectNode(node.id);
  }

  void _handleDoubleTap(BuildContext context) {
    // Open properties dialog
    showDialog(
      context: context,
      builder: (_) => NodePropertiesDialog(node: node),
    );
  }

  void _handleLongPress(BuildContext context) {
    // Show context menu
    showMenu(
      context: context,
      position: RelativeRect.fill,
      items: [
        PopupMenuItem(
          child: Text('Edit'),
          onTap: () => _editNode(),
        ),
        PopupMenuItem(
          child: Text('Delete'),
          onTap: () => controller.removeNode(node.id),
        ),
      ],
    );
  }
}

Selection States

Show visual feedback for selected nodes. The Node class has an isSelected getter that returns the reactive selection state:

dart
Widget buildNodeWidget(BuildContext context, Node<MyData> node) {
  // Use node.isSelected directly - it's a reactive property
  final isSelected = node.isSelected;

  return Container(
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(8),
      border: Border.all(
        color: isSelected ? Colors.blue : Colors.grey[300]!,
        width: isSelected ? 3 : 1,
      ),
      boxShadow: isSelected
          ? [
              BoxShadow(
                color: Colors.blue.withValues(alpha: 0.3),
                blurRadius: 12,
                spreadRadius: 2,
              ),
            ]
          : [
              BoxShadow(
                color: Colors.black12,
                blurRadius: 4,
                offset: Offset(0, 2),
              ),
            ],
    ),
    child: // ...node content
  );
}

Accessing the Controller

If you need access to the controller from within a node widget, use NodeFlowScope:

dart
final controller = NodeFlowScope.of<MyData>(context);

Reactive Nodes with MobX

Since the package uses MobX, you can create reactive node widgets:

dart
class ReactiveNodeWidget extends StatelessWidget {
  final Node<ObservableData> node;

  const ReactiveNodeWidget({required this.node});

  @override
  Widget build(BuildContext context) {
    return Observer(
      builder: (_) => Container(
        decoration: BoxDecoration(
          color: node.data.color.value,
          borderRadius: BorderRadius.circular(8),
          border: Border.all(
            color: node.data.isActive.value
                ? Colors.green
                : Colors.grey,
          ),
        ),
        child: Column(
          children: [
            Text(node.data.title.value),
            if (node.data.isProcessing.value)
              CircularProgressIndicator(),
          ],
        ),
      ),
    );
  }
}

Custom Shapes

Create nodes with custom shapes:

dart
class CircularNodeWidget extends StatelessWidget {
  final Node<MyData> node;

  const CircularNodeWidget({required this.node});

  @override
  Widget build(BuildContext context) {
    final size = node.size.value;

    return Container(
      width: size.width,
      height: size.height,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: Colors.purple[50],
        border: Border.all(color: Colors.purple, width: 2),
      ),
      child: Center(child: Text(node.data.label)),
    );
  }
}

class HexagonNodeWidget extends StatelessWidget {
  final Node<MyData> node;

  const HexagonNodeWidget({required this.node});

  @override
  Widget build(BuildContext context) {
    final size = node.size.value;

    return ClipPath(
      clipper: HexagonClipper(),
      child: Container(
        width: size.width,
        height: size.height,
        color: Colors.teal[50],
        child: Center(child: Text(node.data.label)),
      ),
    );
  }
}

Performance Optimization

Keep node widgets performant:

dart
class StaticNodeWidget extends StatelessWidget {
  final String title;

  const StaticNodeWidget({required this.title});

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(title),
    );
  }
}
dart
nodeBuilder: (context, node) {
  // Only rebuild when node data changes
  return NodeWidget(
    key: ValueKey(node.id),
    node: node,
  );
}
dart
// ❌ Bad: Expensive operation in build
Widget build(BuildContext context) {
  final processedData = expensiveComputation(node.data); // Runs every build!
  return Text(processedData);
}

// ✅ Good: Cache expensive operations
class NodeWidget extends StatefulWidget {
  @override
  State<NodeWidget> createState() => _NodeWidgetState();
}

class _NodeWidgetState extends State<NodeWidget> {
  late String processedData;

  @override
  void initState() {
    super.initState();
    processedData = expensiveComputation(widget.node.data);
  }

  @override
  Widget build(BuildContext context) {
    return Text(processedData);
  }
}

Best Practices

  1. Fixed Sizes: Always respect node.size.width and node.size.height
  2. Overflow Handling: Use overflow: TextOverflow.ellipsis for long text
  3. Accessibility: Add semantic labels for screen readers
  4. Consistent Styling: Maintain visual consistency across node types
  5. Loading States: Show indicators for async operations
  6. Error States: Display error messages within the node
  7. Touch Targets: Ensure interactive elements are at least 44x44 pixels

Common Patterns

dart
Widget buildNodeWithBadge(Node<MyData> node) {
  return Stack(
    clipBehavior: Clip.none,
    children: [
      Container(
        // Main node content
      ),
      Positioned(
        top: -8,
        right: -8,
        child: Container(
          padding: EdgeInsets.all(4),
          decoration: BoxDecoration(
            color: Colors.red,
            shape: BoxShape.circle,
          ),
          child: Text(
            node.data.errorCount.toString(),
            style: TextStyle(color: Colors.white, fontSize: 10),
          ),
        ),
      ),
    ],
  );
}
dart
Widget buildNodeWithProgress(Node<ProcessData> node) {
  return Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Container(
        // Node content
      ),
      LinearProgressIndicator(
        value: node.data.progress,
        backgroundColor: Colors.grey[200],
        color: Colors.blue,
      ),
    ],
  );
}

See Also