DEV3 Technologies home

Validated multi-step stepper

Wizard built on the Material Stepper where each step has its own validator and Continue is blocked until that step passes.

FlutterFormsflutterstepperwizardvalidation

Code

import 'package:flutter/material.dart';

class FlowStep {
  const FlowStep({required this.title, required this.formKey, required this.body, this.summary});
  final String title;
  final GlobalKey<FormState> formKey; // each step validates independently
  final Widget body;
  final String Function()? summary; // shown when the step is collapsed
}

class StepFlow extends StatefulWidget {
  const StepFlow({super.key, required this.steps, required this.onFinish, this.finishLabel = 'Finish'});
  final List<FlowStep> steps;
  final Future<void> Function() onFinish;
  final String finishLabel;

  @override
  State<StepFlow> createState() => _StepFlowState();
}

class _StepFlowState extends State<StepFlow> {
  int _current = 0;
  bool _busy = false;

  Future<void> _next() async {
    final step = widget.steps[_current];
    if (!(step.formKey.currentState?.validate() ?? true)) return;
    if (_current < widget.steps.length - 1) {
      setState(() => _current++);
      return;
    }
    setState(() => _busy = true);
    try {
      await widget.onFinish();
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final last = widget.steps.length - 1;
    return Stepper(
      currentStep: _current,
      onStepTapped: (i) {
        if (i < _current) setState(() => _current = i); // go back freely, never skip ahead
      },
      controlsBuilder: (context, details) => Padding(
        padding: const EdgeInsets.only(top: 12),
        child: Row(children: [
          FilledButton(
            onPressed: _busy ? null : _next,
            child: _busy
                ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
                : Text(_current == last ? widget.finishLabel : 'Continue'),
          ),
          const SizedBox(width: 8),
          if (_current > 0) TextButton(onPressed: () => setState(() => _current--), child: const Text('Back')),
        ]),
      ),
      steps: [
        for (var i = 0; i < widget.steps.length; i++)
          Step(
            title: Text(widget.steps[i].title),
            subtitle: i < _current && widget.steps[i].summary != null ? Text(widget.steps[i].summary!()) : null,
            isActive: i <= _current,
            state: i < _current ? StepState.complete : StepState.indexed,
            content: Form(key: widget.steps[i].formKey, child: widget.steps[i].body),
          ),
      ],
    );
  }
}

How it works

What it solves

Longer forms are easier to finish when split into steps, but a wizard that lets people jump ahead or continue with invalid data only moves the error to the end.

How it works

Each FlowStep owns a GlobalKey<FormState>, and the body is wrapped in its own Form. Pressing Continue validates only the current step; on success the index advances, and on the last step the async onFinish runs with a spinner that disables the button, so double submissions cannot happen.

The stepper's tap handler allows going back to earlier steps but never forward past the current one. Completed steps show an optional summary line, pulled from a closure so it reads the latest field values.

Adapting it

Build the step bodies from TextFormFields with validators and share a controller or model between them. Replace Continue with a custom label per step, or add a review step as the final entry. For wide layouts, set type: StepperType.horizontal.

Pitfalls

  • Form keeps unvalidated state only while mounted; collapsed steps in Stepper stay built, so values survive navigation between steps.
  • Never put the final side effect (such as payment) in a step validator.
  • The summary closure runs on every build; keep it cheap.

How to use it

Copy the file into lib/widgets/. No packages.

StepFlow(
  steps: [FlowStep(title: 'Details', formKey: GlobalKey<FormState>(), body: nameField)],
  onFinish: () async => await submit(),
)

Create the keys once (in State), not inside build.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets