image

07 Jan 2026

9K

35K

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.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is