How to use the Flutter Stepper Widget in Flutter 2.6.0

Paul Halliday

What's one thing that users hate more than anything?

Excessively long forms.

One way to fix that is to break up content into various steps.

With Flutter, this can be done in a variety of ways. Using the PageView widget, for example, to separate the content into separate swipeable screens is a widespread technique.

That'd be the equivalent of a multi-page form.

There's also another technique to break up content into steps: The Flutter Stepper widget.

The best part is, you don't have to compromise the architectural design of your widgets, as you'll see soon!

Creating a Stepper

This section will teach you to utilize the Flutter Material 'Stepper' widget to build a checkout form with multiple steps.

Start by adding a new Stepper widget to your project. I've gone ahead and created a new file named checkout.dart and added the following content:

import 'package:flutter/material.dart';

class CheckoutPage extends StatefulWidget {
  const CheckoutPage({Key? key}) : super(key: key);

  
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> {
  static const _steps = [
    Step(
      title: Text('Address'),
      content: _AddressForm(),
    ),
    Step(
      title: Text('Card Details'),
      content: _CardForm(),
    ),
    Step(
      title: Text('Overview'),
      content: _Overview(),
    )
  ];

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Checkout'),
      ),
      body: Stepper(
        steps: _steps,
      ),
    );
  }
}

class _AddressForm extends StatelessWidget {
  const _AddressForm({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextFormField(
          decoration: const InputDecoration(
            labelText: 'Street',
          ),
        ),
        TextFormField(
          decoration: const InputDecoration(
            labelText: 'City',
          ),
        ),
        TextFormField(
          decoration: const InputDecoration(
            labelText: 'Postcode',
          ),
        ),
      ],
    );
  }
}

class _CardForm extends StatelessWidget {
  const _CardForm({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextFormField(
          decoration: const InputDecoration(
            labelText: 'Card number',
          ),
        ),
        TextFormField(
          decoration: const InputDecoration(
            labelText: 'Expiry date',
          ),
        ),
        TextFormField(
          decoration: const InputDecoration(
            labelText: 'CVV',
          ),
        ),
      ],
    );
  }
}

class _Overview extends StatelessWidget {
  const _Overview({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Column(
      children: const [
        Center(child: Text('Thank you for your order!')),
      ],
    );
  }
}

I've then made this the default widget inside of my MaterialApp at main.dart:

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Stepper',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const CheckoutPage(),
    );
  }
}

By combining the Stepper widget with a series of Step widgets, you're able to set the title, subtitle, and other metadata that determines how the step appears on the screen.

You should have this so far:

Moving through the steps

You now have a Stepper!

Unfortunately, nothing works when you click on either of the steps yet. You'll also notice that the Continue and Cancel buttons are both disabled.

This is because the Stepper doesn't come with any inherent step-through functionality - it's up to you to attach event handlers to help move through your stepper.

Start this process by adding the currentStep property to the Stepper widget:

class _CheckoutPageState extends State<CheckoutPage> {
  static const _steps = [
    Step(
      title: Text('Address'),
      content: _AddressForm(),
    ),
    Step(
      title: Text('Card Details'),
      content: _CardForm(),
    ),
    Step(
      title: Text('Overview'),
      content: _Overview(),
    )
  ];
  int _currentStep = 0;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Checkout'),
      ),
      body: Stepper(
        currentStep: _currentStep,
        steps: _steps,
      ),
    );
  }
}

The currentStep property is a int that represents the current step in the stepper. You can use this to programmatically move through the steps with the onStepCancel, onStepContinue and onStepTapped callbacks.

Let's add these callbacks to the Stepper widget:

Widget build(BuildContext context) {
  return Scaffold(
    body: Stepper(
      onStepTapped: (step) => setState(() => _currentStep = step),
      onStepContinue: () {
        setState(() {
          if (_currentStep < _steps.length - 1) {
            _currentStep += 1;
          } else {
            _currentStep = 0;
          }
        });
      },
      onStepCancel: () {
        setState(() {
          if (_currentStep > 0) {
            _currentStep -= 1;
          } else {
            _currentStep = 0;
          }
        });
      },
      currentStep: _currentStep,
      steps: _steps,
    ),
  );
}

Your stepper will now have the following functionality:

  • Tap on the step to move to that step
  • Tap on the Continue button to move to the next step
  • Tap on the Cancel button to move to the previous step
  • Ensure that the Continue and Cancel buttons are enabled when appropriate.

It'll look like this:

Customising the Stepper controls

In the majority of cases, you're likely going to want to customise the default stepper controls. For example, you may want to change the Continue and Cancel buttons to say Next and Back respectively.

This is done by adding a controlsBuilder property to the Stepper widget.

The controlsBuilder returns a ControlsDetails that can be used to customise the stepper controls. This gives you access to the onStepContinue and onStepCancel callbacks and other metadata.

Add the controlsBuilder property to the Stepper widget:


Widget build(BuildContext context) {
  return Scaffold(
    body: Stepper(
      controlsBuilder: (BuildContext context, ControlsDetails controls) {
        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 16.0),
          child: Row(
            children: <Widget>[
              ElevatedButton(
                onPressed: controls.onStepContinue,
                child: const Text('NEXT'),
              ),
              if (_currentStep != 0)
                TextButton(
                  onPressed: controls.onStepCancel,
                  child: const Text(
                    'BACK',
                    style: TextStyle(color: Colors.grey),
                  ),
                ),
            ],
          ),
        );
      },
      // ...
    )
  );
}

The stepper controls are now customised to say Next and Back. The Back button is also hidden if the stepper is at the first step.

Prior to Flutter 2.6.0, the controlsBuilder returns two callbacks: onStepContinue and onStepCancel. These callbacks are now replaced with a single ControlsDetails object.

Your stepper should now look like this:

Stepper with custom controls

Active and completed steps

The Step widget has a state property that can be used to indicate whether the step is active or completed. You can use this to customise the stepper's appearance.

  • StepState.indexed - The step is active and shows a number.
  • StepState.error - The step shows an error.
  • StepState.disabled - The step is disabled.
  • StepState.editing - The step is active and shows a pencil icon.
  • StepState.complete - The step is completed.

The default state is StepState.indexed.

The Step widget also has a isActive property that can be used to indicate whether the step is active or not.

Update your application to include both state and isActive:

class _CheckoutPageState extends State<CheckoutPage> {
  int _currentStep = 0;

  _stepState(int step) {
    if (_currentStep > step) {
      return StepState.complete;
    } else {
      return StepState.editing;
    }
  }

  _steps() => [
        Step(
          title: const Text('Address'),
          content: const _AddressForm(),
          state: _stepState(0),
          isActive: _currentStep == 0,
        ),
        Step(
          title: const Text('Card Details'),
          content: const _CardForm(),
          state: _stepState(1),
          isActive: _currentStep == 1,
        ),
        Step(
          title: const Text('Overview'),
          content: const _Overview(),
          state: _stepState(2),
        )
      ];

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stepper(
        controlsBuilder: (BuildContext context, ControlsDetails controls) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 16.0),
            child: Row(
              children: <Widget>[
                ElevatedButton(
                  onPressed: controls.onStepContinue,
                  child: const Text('NEXT'),
                ),
                if (_currentStep != 0)
                  TextButton(
                    onPressed: controls.onStepCancel,
                    child: const Text(
                      'BACK',
                      style: TextStyle(color: Colors.grey),
                    ),
                  ),
              ],
            ),
          );
        },
        onStepTapped: (step) => setState(() => _currentStep = step),
        onStepContinue: () {
          setState(() {
            if (_currentStep < _steps().length - 1) {
              _currentStep += 1;
            } else {
              _currentStep = 0;
            }
          });
        },
        onStepCancel: () {
          setState(() {
            if (_currentStep > 0) {
              _currentStep -= 1;
            } else {
              _currentStep = 0;
            }
          });
        },
        currentStep: _currentStep,
        steps: _steps(),
      ),
    );
  }
}

In this instance, the _stepState function is used to determine the state of the step. If the step is completed, the StepState.complete is used. Otherwise, the StepState.editing is used.

The _steps variable has been converted into a function that returns the list of Step widgets. This is done to use the _currentStep variable to determine which step is active and the current state.

In reality, you'll want to be a bit more specific about the state of the step. For example, you may want to show the StepState.error state if the step is not completed or invalid.

Horizontal and vertical steppers

The Stepper widget can be displayed horizontally or vertically. This can be done by assigning the type property to either StepperType.vertical or StepperType.horizontal.

As you may expect:

  • StepperType.vertical - The stepper is displayed vertically.
  • StepperType.horizontal - The stepper is displayed horizontally.

Let's see an example of a horizontal stepper. Change the type property to StepperType.horizontal:


Widget build(BuildContext context) {
  return Scaffold(
    body: Stepper(
      type: StepperType.horizontal,
      // ...
    )
  );
}

It should now look like this:

Horizontal stepper

Keep in mind that the horizontal stepper may be difficult to use on small screens.

Conclusion

In this article we looked at how to use the Stepper widget to display a series of steps. We also looked at how to customise the stepper's appearance, and how to use the Stepper widget to display a horizontal or vertical series of steps.

Paul Halliday's avatar
Paul Halliday's avatar

Paul Halliday

Creator ● developer.school

Passionate about cross-platform web and mobile development.

developer.school

© 2021 developer.school. All rights reserved.

© 2021 developer.school | All rights reserved

Subscribe to our newsletter

The latest news, articles, and resources, sent to your inbox weekly.