Creating a Multi-Level Step Indicator Widget in Flutter
Step indicators are crucial UI components for guiding users through multi-stage processes, such as checkout flows, onboarding tutorials, or multi-part forms. While linear step indicators are common, many real-world processes involve sub-steps or hierarchical structures. A multi-level step indicator provides a more intuitive way to visualize and navigate these complex workflows within a Flutter application.
Understanding the Concept
A multi-level step indicator extends the traditional linear model by allowing steps to have children, creating a tree-like structure. This approach helps users understand the overall progress, while also seeing the granular tasks involved in each major step. Key characteristics include:
- Hierarchy: Steps can be nested, with parent steps containing one or more child steps.
- Visual Indication: Different levels are visually distinguished, often through indentation or varying line styles.
- State Management: Each step (and its children) can have a status (e.g., pending, active, completed).
- Navigation: The ability to move between steps, automatically updating the state of parent and child steps.
Designing the Data Model
The foundation of a multi-level step indicator is a robust data model that can represent the hierarchical nature of the steps. We'll define a simple StepItem class that can recursively hold its children.
import 'package:flutter/material.dart';
enum StepStatus {
pending,
active,
completed,
}
class StepItem {
final String title;
final String? subtitle;
final List<StepItem> children;
StepStatus status; // Can be managed externally or internally
StepItem({
required this.title,
this.subtitle,
this.children = const [],
this.status = StepStatus.pending,
});
// Helper to determine if a step (and its children) are completed
bool get isCompleted => status == StepStatus.completed;
// Helper to determine if a step (or any of its children) are active
bool get isActive => status == StepStatus.active;
}
Building the Recursive Step Tile Widget
To render each step and its children, we'll create a reusable widget, let's call it _StepTile. This widget will be responsible for drawing a single step's indicator, title, and then recursively rendering its children with appropriate indentation and connecting lines.
class _StepTile extends StatelessWidget {
final StepItem step;
final int level;
final bool isLastChild;
final bool hasNextSibling;
final ValueChanged<StepItem> onStepTapped;
const _StepTile({
required this.step,
this.level = 0,
this.isLastChild = false,
this.hasNextSibling = false,
required this.onStepTapped,
});
Widget _buildIndicator() {
IconData icon;
Color color;
switch (step.status) {
case StepStatus.completed:
icon = Icons.check_circle;
color = Colors.green;
break;
case StepStatus.active:
icon = Icons.radio_button_checked;
color = Colors.blue;
break;
case StepStatus.pending:
icon = Icons.radio_button_unchecked;
color = Colors.grey;
break;
}
return Icon(icon, color: color, size: 24);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => onStepTapped(step),
child: Padding(
padding: EdgeInsets.only(left: level * 20.0), // Indentation
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildIndicator(),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step.title,
style: TextStyle(
fontWeight: step.isActive ? FontWeight.bold : FontWeight.normal,
color: step.isActive ? Colors.blue : Colors.black87,
),
),
if (step.subtitle != null)
Text(
step.subtitle!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
),
),
),
if (step.children.isNotEmpty)
Padding(
padding: EdgeInsets.only(left: (level * 20.0) + 12), // Align vertical line
child: Stack(
children: [
// Vertical line connecting to children
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(
width: 2,
color: Colors.grey[300],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(step.children.length, (index) {
final childStep = step.children[index];
return _StepTile(
step: childStep,
level: level + 1,
isLastChild: index == step.children.length - 1,
hasNextSibling: index < step.children.length - 1,
onStepTapped: onStepTapped,
);
}),
),
],
),
),
// Spacer between main steps, or if it's not the very last item in a parent's children
if (!isLastChild && level == 0) // Only add divider for top-level steps
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Divider(height: 1, thickness: 1),
)
else if (hasNextSibling && level > 0) // Add vertical line segment between children of the same parent
Padding(
padding: EdgeInsets.only(left: (level * 20.0) + 12, bottom: 8),
child: Container(
height: 12, // Small vertical line segment
width: 2,
color: Colors.grey[300],
),
),
],
);
}
}
The Main Multi-Level Step Indicator Widget
This widget will hold the list of StepItems and manage their state. It will iterate through the top-level steps and use our _StepTile to render them recursively. It will also provide methods to navigate or update step statuses.
class MultiLevelStepIndicator extends StatefulWidget {
final List<StepItem> steps;
final int initialActiveStepIndex; // For top-level steps
final ValueChanged<StepItem>? onStepTapped;
const MultiLevelStepIndicator({
super.key,
required this.steps,
this.initialActiveStepIndex = 0,
this.onStepTapped,
});
@override
State<MultiLevelStepIndicator> createState() => _MultiLevelStepIndicatorState();
}
class _MultiLevelStepIndicatorState extends State<MultiLevelStepIndicator> {
late List<StepItem> _currentSteps;
@override
void initState() {
super.initState();
_currentSteps = _initializeSteps(widget.steps, widget.initialActiveStepIndex);
}
// Deep copy and initial status setup
List<StepItem> _initializeSteps(List<StepItem> originalSteps, int initialIndex) {
List<StepItem> newSteps = [];
for (int i = 0; i < originalSteps.length; i++) {
StepItem original = originalSteps[i];
StepStatus status = StepStatus.pending;
if (i < initialIndex) {
status = StepStatus.completed;
} else if (i == initialIndex) {
status = StepStatus.active;
}
newSteps.add(StepItem(
title: original.title,
subtitle: original.subtitle,
children: _initializeChildSteps(original.children, status == StepStatus.active),
status: status,
));
}
return newSteps;
}
List<StepItem> _initializeChildSteps(List<StepItem> originalChildren, bool parentIsActive) {
if (!parentIsActive) return originalChildren.map((e) => StepItem(title: e.title, subtitle: e.subtitle, children: e.children, status: StepStatus.pending)).toList();
// If parent is active, make the first child active by default, or all children pending.
List<StepItem> newChildren = [];
for (int i = 0; i < originalChildren.length; i++) {
StepItem original = originalChildren[i];
StepStatus status = StepStatus.pending;
if (i == 0) { // First child is active if parent is active
status = StepStatus.active;
}
newChildren.add(StepItem(
title: original.title,
subtitle: original.subtitle,
children: _initializeChildSteps(original.children, status == StepStatus.active),
status: status,
));
}
return newChildren;
}
// Method to update the status of steps
void _updateStepStatus(StepItem tappedStep) {
setState(() {
bool foundActive = false;
for (var topLevelStep in _currentSteps) {
_recursivelyUpdateStatus(topLevelStep, tappedStep, false, foundActive);
if (topLevelStep.status == StepStatus.active) {
foundActive = true;
}
}
});
widget.onStepTapped?.call(tappedStep);
}
void _recursivelyUpdateStatus(StepItem currentStep, StepItem targetStep, bool parentCompleted, bool parentActiveBefore) {
// If the target step is found, make it active. All steps before it are completed. All steps after are pending.
if (currentStep == targetStep) {
currentStep.status = StepStatus.active;
} else if (parentCompleted || (parentActiveBefore && currentStep.status != StepStatus.active && currentStep.status != StepStatus.completed)) {
currentStep.status = StepStatus.pending; // Reset steps after active
} else if (currentStep.status == StepStatus.active) {
currentStep.status = StepStatus.completed; // If current was active, it's now completed
} else if (currentStep.status != StepStatus.completed) {
currentStep.status = StepStatus.pending;
}
bool currentCompleted = currentStep.status == StepStatus.completed;
bool currentActive = currentStep.status == StepStatus.active;
for (var child in currentStep.children) {
_recursivelyUpdateStatus(child, targetStep, currentCompleted, currentActive);
}
// After updating children, if all children are completed, parent can be completed too
if (currentStep.children.isNotEmpty && currentStep.children.every((child) => child.isCompleted)) {
currentStep.status = StepStatus.completed;
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(_currentSteps.length, (index) {
final step = _currentSteps[index];
return _StepTile(
step: step,
level: 0,
isLastChild: index == _currentSteps.length - 1,
hasNextSibling: index < _currentSteps.length - 1,
onStepTapped: _updateStepStatus,
);
}),
);
}
}
Example Usage
Here's how you might integrate the MultiLevelStepIndicator into a Flutter application:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Multi-Level Step Indicator Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<StepItem> _steps = [
StepItem(
title: 'Personal Information',
subtitle: 'Provide your name and contact details.',
children: [
StepItem(title: 'Basic Details', subtitle: 'Name, Email'),
StepItem(title: 'Address', subtitle: 'Street, City, Postal Code'),
StepItem(title: 'Contact Preferences'),
],
),
StepItem(
title: 'Account Setup',
subtitle: 'Create your username and password.',
children: [
StepItem(title: 'Choose Username'),
StepItem(title: 'Set Password', children: [
StepItem(title: 'Minimum 8 characters'),
StepItem(title: 'Include special character'),
]),
StepItem(title: 'Security Questions'),
],
),
StepItem(
title: 'Payment Details',
subtitle: 'Select payment method.',
),
StepItem(
title: 'Review & Confirm',
subtitle: 'Final check before submission.',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Multi-Level Steps'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: MultiLevelStepIndicator(
steps: _steps,
initialActiveStepIndex: 0,
onStepTapped: (step) {
print('Tapped: ${step.title}');
// You can add logic here to navigate to a specific form section
},
),
),
);
}
}
Conclusion
Creating a multi-level step indicator in Flutter involves a recursive approach to both data modeling and UI rendering. By defining a hierarchical StepItem and a recursive _StepTile widget, we can effectively visualize complex workflows. The main MultiLevelStepIndicator widget then manages the overall state and user interactions, providing a robust and flexible solution for guiding users through nested processes. This implementation offers a solid foundation that can be further enhanced with custom animations, different visual styles, and more sophisticated state management solutions like Provider or BLoC for larger applications.