...
...
[ FIELD NOTE ] // APRIL 28, 2026

Flutter Node Graph UI: Building Interactive Workflows

Building node-based interfaces in Flutter opens up possibilities for visual programming tools, workflow builders, and data flow editors. Here's how to create draggable, connectable node graphs from scratch.

flutternode-graphui-componentscustom-painterinteractive-ui
V
VooStack Team
April 28, 2026
7 min read

Flutter Node Graph UI: Building Interactive Workflows

Node-based interfaces power everything from Blender's material editor to Zapier's automation builder. They let users create complex logic by connecting simple building blocks. But building these interfaces from scratch in Flutter isn't straightforward.

We've been working on node graph functionality across multiple AgileStack projects, from workflow builders to data pipeline editors. The challenge isn't just drawing nodes and connections. It's handling the interaction model, managing state efficiently, and keeping performance smooth when users create complex graphs.

The Node Graph Problem

Consider a typical workflow builder. Users need to:

  • Drag nodes around the canvas
  • Connect outputs from one node to inputs of another
  • Select multiple nodes and move them together
  • Zoom and pan the entire canvas
  • Delete nodes and connections
  • Undo/redo operations

Each interaction affects multiple parts of your UI. When a user drags a node, you're updating its position, redrawing any connected lines, and potentially triggering layout recalculations for the entire graph.

The naive approach breaks down fast. Store everything in a single state object and watch your frame rate tank as graphs grow beyond 20-30 nodes.

Core Architecture: Separating Concerns

Effective node graphs separate three concerns: the data model, the visual representation, and the interaction layer.

Data Model

Start with clean data structures:

class NodeGraph {
  final Map<String, GraphNode> nodes;
  final Map<String, Connection> connections;
  final Rect viewport;
  final double zoom;
  
  NodeGraph({
    required this.nodes,
    required this.connections,
    required this.viewport,
    this.zoom = 1.0,
  });
}

class GraphNode {
  final String id;
  final String type;
  final Offset position;
  final Map<String, dynamic> data;
  final List<NodePort> inputs;
  final List<NodePort> outputs;
  
  // Constructor and methods...
}

class Connection {
  final String id;
  final String fromNodeId;
  final String fromPortId;
  final String toNodeId;
  final String toPortId;
  
  // Constructor and methods...
}

This separation lets you serialize graphs, implement undo/redo, and test logic without dealing with widgets.

Visual Layer

The visual layer transforms data into widgets. We use a custom painter for connections and positioned widgets for nodes:

class NodeGraphCanvas extends StatefulWidget {
  final NodeGraph graph;
  final Function(NodeGraph) onGraphChanged;
  
  @override
  _NodeGraphCanvasState createState() => _NodeGraphCanvasState();
}

class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: _handleCanvasPan,
      onScaleUpdate: _handleCanvasZoom,
      child: CustomPaint(
        painter: ConnectionPainter(widget.graph.connections, widget.graph.nodes),
        child: Stack(
          children: widget.graph.nodes.values.map((node) => 
            _buildNodeWidget(node)
          ).toList(),
        ),
      ),
    );
  }
}

Interaction Layer

The interaction layer handles gestures and updates the data model. This is where complexity lives:

void _handleNodeDragStart(String nodeId, Offset globalPosition) {
  setState(() {
    _dragState = DragState(
      nodeId: nodeId,
      startPosition: widget.graph.nodes[nodeId]!.position,
      startGlobalPosition: globalPosition,
    );
  });
}

void _handleNodeDragUpdate(Offset globalDelta) {
  if (_dragState == null) return;
  
  final newPosition = _dragState!.startPosition + globalDelta;
  final updatedNode = widget.graph.nodes[_dragState!.nodeId]!
      .copyWith(position: newPosition);
  
  final updatedGraph = widget.graph.copyWith(
    nodes: Map.from(widget.graph.nodes)..[updatedNode.id] = updatedNode,
  );
  
  widget.onGraphChanged(updatedGraph);
}

Connection Drawing and Hit Testing

Connections need smooth curves that respond to user interaction. Cubic Bezier curves work well:

class ConnectionPainter extends CustomPainter {
  final Map<String, Connection> connections;
  final Map<String, GraphNode> nodes;
  
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;
    
    for (final connection in connections.values) {
      final path = _createConnectionPath(connection);
      canvas.drawPath(path, paint);
    }
  }
  
  Path _createConnectionPath(Connection connection) {
    final fromPos = _getPortPosition(connection.fromNodeId, connection.fromPortId);
    final toPos = _getPortPosition(connection.toNodeId, connection.toPortId);
    
    final path = Path();
    path.moveTo(fromPos.dx, fromPos.dy);
    
    // Create smooth bezier curve
    final controlOffset = (toPos.dx - fromPos.dx).abs() * 0.5;
    path.cubicTo(
      fromPos.dx + controlOffset, fromPos.dy,
      toPos.dx - controlOffset, toPos.dy,
      toPos.dx, toPos.dy,
    );
    
    return path;
  }
}

Hit testing connections requires checking if a point lies near the curve. Flutter's Path.contains() doesn't work for stroked paths, so we sample points along the curve:

bool _isPointNearConnection(Offset point, Connection connection, double threshold) {
  final path = _createConnectionPath(connection);
  final metrics = path.computeMetrics().first;
  
  // Sample points along the path
  for (double distance = 0; distance < metrics.length; distance += 5.0) {
    final tangent = metrics.getTangentForOffset(distance);
    if (tangent?.position != null) {
      final pathPoint = tangent!.position;
      if ((pathPoint - point).distance < threshold) {
        return true;
      }
    }
  }
  
  return false;
}

Performance Optimization

Large graphs (100+ nodes) need performance consideration. The biggest wins come from:

Viewport Culling

Only render nodes visible in the current viewport:

List<GraphNode> _getVisibleNodes() {
  final viewportRect = Rect.fromLTWH(
    -widget.graph.viewport.left / widget.graph.zoom,
    -widget.graph.viewport.top / widget.graph.zoom,
    MediaQuery.of(context).size.width / widget.graph.zoom,
    MediaQuery.of(context).size.height / widget.graph.zoom,
  );
  
  return widget.graph.nodes.values.where((node) {
    final nodeRect = Rect.fromCenter(
      center: node.position,
      width: 200, // Approximate node width
      height: 100, // Approximate node height
    );
    return viewportRect.overlaps(nodeRect);
  }).toList();
}

Connection Optimization

Connections between off-screen nodes don't need detailed curves. Use straight lines or skip them entirely:

Path _createConnectionPath(Connection connection, bool detailed) {
  final fromPos = _getPortPosition(connection.fromNodeId, connection.fromPortId);
  final toPos = _getPortPosition(connection.toNodeId, connection.toPortId);
  
  final path = Path();
  path.moveTo(fromPos.dx, fromPos.dy);
  
  if (detailed) {
    // Full bezier curve for visible connections
    final controlOffset = (toPos.dx - fromPos.dx).abs() * 0.5;
    path.cubicTo(
      fromPos.dx + controlOffset, fromPos.dy,
      toPos.dx - controlOffset, toPos.dy,
      toPos.dx, toPos.dy,
    );
  } else {
    // Simple line for distant connections
    path.lineTo(toPos.dx, toPos.dy);
  }
  
  return path;
}

State Management

Use immutable data structures but be smart about updates. Don't rebuild the entire graph when one node moves:

class NodeGraphNotifier extends ChangeNotifier {
  NodeGraph _graph;
  
  void updateNodePosition(String nodeId, Offset newPosition) {
    final node = _graph.nodes[nodeId]!;
    final updatedNode = node.copyWith(position: newPosition);
    
    _graph = _graph.copyWith(
      nodes: Map.from(_graph.nodes)..[nodeId] = updatedNode,
    );
    
    // Only notify listeners interested in this specific node
    notifyListeners();
  }
}

Advanced Features

Once you have basic node graphs working, users want more:

Multi-Selection

Track selected nodes and move them as a group:

class SelectionState {
  final Set<String> selectedNodeIds;
  final Rect? selectionRect;
  
  SelectionState({
    this.selectedNodeIds = const {},
    this.selectionRect,
  });
}

void _handleMultiNodeDrag(Offset delta) {
  final updates = <String, GraphNode>{};
  
  for (final nodeId in _selectionState.selectedNodeIds) {
    final node = widget.graph.nodes[nodeId]!;
    updates[nodeId] = node.copyWith(
      position: node.position + delta,
    );
  }
  
  final updatedGraph = widget.graph.copyWith(
    nodes: Map.from(widget.graph.nodes)..addAll(updates),
  );
  
  widget.onGraphChanged(updatedGraph);
}

Undo/Redo

Keep a history stack of graph states:

class GraphHistory {
  final List<NodeGraph> _history = [];
  int _currentIndex = -1;
  
  void push(NodeGraph graph) {
    // Remove any "future" history if we're not at the end
    if (_currentIndex < _history.length - 1) {
      _history.removeRange(_currentIndex + 1, _history.length);
    }
    
    _history.add(graph);
    _currentIndex++;
    
    // Keep history bounded
    if (_history.length > 50) {
      _history.removeAt(0);
      _currentIndex--;
    }
  }
  
  NodeGraph? undo() {
    if (_currentIndex > 0) {
      _currentIndex--;
      return _history[_currentIndex];
    }
    return null;
  }
  
  NodeGraph? redo() {
    if (_currentIndex < _history.length - 1) {
      _currentIndex++;
      return _history[_currentIndex];
    }
    return null;
  }
}

Key Takeaways

Building flutter node graph interfaces requires thoughtful architecture:

  • Separate data, visual, and interaction concerns from the start
  • Use immutable data structures but optimize updates
  • Implement viewport culling for large graphs
  • Custom painters work well for connections, positioned widgets for nodes
  • Plan for advanced features like multi-selection and undo/redo early

The interaction model is the hardest part. Users expect node graphs to feel responsive and natural. That means smooth dragging, accurate hit testing, and immediate visual feedback.

Start simple. Build basic nodes and connections first. Add complexity incrementally as you understand your users' workflows. The architecture we've outlined scales from simple prototypes to production tools handling hundreds of nodes.

Your next step: implement the basic data structures and get a single draggable node working. Everything else builds from there.

// Topics
flutternode-graphui-componentscustom-painterinteractive-ui
// Authored By
V

VooStack Team

[ TRANSMIT ]

Share this article