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:
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:
- Container: Defines size, decoration, borders
- Content: Title, icon, description, data
- Interactivity: Gesture handlers for taps, double-taps
- State Indicators: Selection, hover, disabled states
Recommended Structure
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:
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);
}
}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,
),
),
],
),
),
);
}
}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,
),
],
],
),
),
);
}
}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:
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:
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:
final controller = NodeFlowScope.of<MyData>(context);Reactive Nodes with MobX
Since the package uses MobX, you can create reactive node widgets:
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:
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:
class StaticNodeWidget extends StatelessWidget {
final String title;
const StaticNodeWidget({required this.title});
@override
Widget build(BuildContext context) {
return Container(
child: Text(title),
);
}
}nodeBuilder: (context, node) {
// Only rebuild when node data changes
return NodeWidget(
key: ValueKey(node.id),
node: node,
);
}// ❌ 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
- Fixed Sizes: Always respect
node.size.widthandnode.size.height - Overflow Handling: Use
overflow: TextOverflow.ellipsisfor long text - Accessibility: Add semantic labels for screen readers
- Consistent Styling: Maintain visual consistency across node types
- Loading States: Show indicators for async operations
- Error States: Display error messages within the node
- Touch Targets: Ensure interactive elements are at least 44x44 pixels
Common Patterns
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),
),
),
),
],
);
}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
- Nodes - Understanding node concepts
- NodeFlowEditor - Main editor component
- Theming - Styling and theming