Node Widget
Building custom node widgets for your flow editor
Node Widget
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>(
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) {
return Container(
width: node.size.width,
height: node.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);
}
}Example: Start Node
class StartNodeWidget extends StatelessWidget {
final Node<MyData> node;
const StartNodeWidget({required this.node});
@override
Widget build(BuildContext context) {
return Container(
width: node.size.width,
height: node.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,
),
),
],
),
),
);
}
}Example: Process Node
class ProcessNodeWidget extends StatelessWidget {
final Node<ProcessData> node;
const ProcessNodeWidget({required this.node});
@override
Widget build(BuildContext context) {
return Container(
width: node.size.width,
height: node.size.height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue, width: 2),
boxShadow: [
BoxShadow(
color: Colors.blue.withOpacity(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,
),
],
],
),
),
);
}
}Example: Condition Node
class ConditionNodeWidget extends StatelessWidget {
final Node<ConditionData> node;
const ConditionNodeWidget({required this.node});
@override
Widget build(BuildContext context) {
return Container(
width: node.size.width,
height: node.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:
Widget buildNodeWidget(BuildContext context, Node<MyData> node) {
final controller = NodeFlowController.of(context);
final isSelected = controller.selectedNodeIds.contains(node.id);
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.withOpacity(0.3),
blurRadius: 12,
spreadRadius: 2,
),
]
: [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: // ...node content
);
}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;
@override
Widget build(BuildContext context) {
return Container(
width: node.size.width,
height: node.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;
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: HexagonClipper(),
child: Container(
width: node.size.width,
height: node.size.height,
color: Colors.teal[50],
child: Center(child: Text(node.data.label)),
),
);
}
}Performance Optimization
Keep node widgets performant:
1. Use const constructors
class StaticNodeWidget extends StatelessWidget {
final String title;
const StaticNodeWidget({required this.title});
@override
Widget build(BuildContext context) {
return Container(
child: Text(title),
);
}
}2. Minimize rebuilds
nodeBuilder: (context, node) {
// Only rebuild when node data changes
return NodeWidget(
key: ValueKey(node.id),
node: node,
);
}3. Avoid expensive operations
// ❌ 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
Node with Status Badge
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),
),
),
),
],
);
}Node with Progress Bar
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