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
Formkeeps unvalidated state only while mounted; collapsed steps inStepperstay 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.