💫

【Flutter】Stepperクラスを使用してウィザード形式のアプリを作成する

12 min read

FlutterでWindowsアプリ及びWebアプリを手を付け始めましたが、画面内にステップが表示されるウィザード形式のアプリって作りたくなりますよね?(なりません?)

ということで、調べてみたらちゃんとクラスがありました。

https://api.flutter.dev/flutter/material/Stepper-class.html

ひとまず、こちらを使用して見たいと思います。

サンプル確認

まず、サンプルの動作確認をしてみます。
以下のコマンドでサンプルが取得できるので起動します。

flutter create --sample=material.Stepper.1 mysample
サンプルのコード
main.dart
/// Flutter code sample for Stepper

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

/// This is the main application widget.
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  static const String _title = 'Flutter Code Sample';

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Center(
          child: MyStatefulWidget(),
        ),
      ),
    );
  }
}

/// This is the stateful widget that the main application instantiates.
class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({Key? key}) : super(key: key);

  
  _MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

/// This is the private State class that goes with MyStatefulWidget.
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  int _index = 0;

  
  Widget build(BuildContext context) {
    return Stepper(
      currentStep: _index,
      onStepCancel: () {
        if (_index > 0) {
          setState(() {
            _index -= 1;
          });
        }
      },
      onStepContinue: () {
        if (_index <= 0) {
          setState(() {
            _index += 1;
          });
        }
      },
      onStepTapped: (int index) {
        setState(() {
          _index = index;
        });
      },
      steps: <Step>[
        Step(
          title: const Text('Step 1 title'),
          content: Container(
              alignment: Alignment.centerLeft,
              child: const Text('Content for Step 1')),
        ),
        const Step(
          title: Text('Step 2 title'),
          content: Text('Content for Step 2'),
        ),
      ],
    );
  }
}

そのまま立ち上げると、垂直形式となります。

return Stepper( 直下にtypeを設定すると水平形式となります。

type: StepperType.horizontal,

これだけでもそこそこな見栄えですが、カスタマイズしていきます。

カスタマイズ

ステップ増減

単純にstepsの項目を増やせば増えます。

      steps: <Step>[
        Step(
          title: const Text('Step 1 title'),
          content: Container(
              alignment: Alignment.centerLeft,
              child: const Text('Content for Step 1')),
        ),
        const Step(
          title: Text('Step 2 title'),
          content: Text('Content for Step 2'),
        ),
	// 以下追加
        const Step(
          title: Text('Step 3 title'),
          content: Text('Content for Step 3'),
        ),
      ],

タイトル、アクティブ、状態変更

titleで文字列、isActiveでステップのアクティブ化(色が付く)、stateでステップの状態(アイコン)を変更できます。

      steps: <Step>[
        Step(
          title: const Text('アクティブ化'),
          isActive: true,
          content: Container(
              alignment: Alignment.centerLeft,
              child: const Text('Content for Step 1')),
        ),
        const Step(
          title: Text('編集中'),
          state: StepState.editing,
          content: Text('Content for Step 2'),
        ),
        const Step(
          title: Text('完了'),
          state: StepState.complete,
          content: Text('Content for Step 3'),
        ),
        const Step(
          title: Text('エラー'),
          state: StepState.error,
          content: Text('Content for Step 4'),
        ),
      ],

タイトルの変更、サブタイトル追加

タイトルにはWidgetを配置出来るのでテキストに限らずImageなどを置くこともできます。
また、サブタイトルも設定可能です。

      steps: <Step>[
        Step(
          title: const Image(
              image: AssetImage('images/moon.png'),
              height: 40,
              width: 50,
              fit: BoxFit.contain),
          subtitle: const Text('ステップ1のサブタイトル'),
          isActive: true,
          content: Container(
              alignment: Alignment.centerLeft,
              child: const Text('Content for Step 1')),
        ),
        const Step(
          title: Text('ステップ2のタイトル'),
          subtitle: const Text('ステップ2のサブタイトル'),
          content: Text('Content for Step 2'),
        ),
        const Step(
          title: Text('Step3 title'),
          state: StepState.complete,
          content: Text('Content for Step 3'),
        ),
      ],

CONTINUE, CANCELボタン変更

サンプルだと次へ、戻るボタンがCONTINUE, CANCELとなっており場所も固定です。
こちらを変更してみましょう。
return Stepper( 直下に以下を追加します。

      controlsBuilder: (BuildContext context,
          {VoidCallback? onStepContinue, VoidCallback? onStepCancel}) {
        return Container(
          alignment: Alignment.bottomCenter,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              ElevatedButton(
                onPressed: onStepCancel,
                child: const Text('戻る'),
              ),
              ElevatedButton(
                onPressed: onStepContinue,
                child: const Text('次へ'),
              ),
            ],
          ),
        );
      },

まとめ

上記のカスタマイズを組み合わせて簡単なアプリを作ってみました。
入力チェックなどの条件を含めれば、それなりのウィザード形式アプリが作れるのではないでしょうか。

コード
main.dart
/// Flutter code sample for Stepper

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(const MyApp());

/// This is the main application widget.
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  static const String _title = 'Flutter Code Sample';

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Center(
          child: MyStatefulWidget(),
        ),
      ),
    );
  }
}

/// This is the stateful widget that the main application instantiates.
class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({Key? key}) : super(key: key);

  
  _MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

/// This is the private State class that goes with MyStatefulWidget.
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  int _index = 0;
  var _step2Input = TextEditingController(text: null);

  
  Widget build(BuildContext context) {
    return Stepper(
      controlsBuilder: (BuildContext context,
          {VoidCallback? onStepContinue, VoidCallback? onStepCancel}) {
        return Container(
          alignment: Alignment.bottomCenter,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              ElevatedButton(
                onPressed: _index == 0 ? null : onStepCancel,
                child: _index == 3 ? Text('もう一度入力する') : Text('戻る'),
              ),
              ElevatedButton(
                onPressed:
                    (_index == 3) || (getStep2State(_index) == StepState.error)
                        ? null
                        : onStepContinue,
                child: _index == 2 ? Text('確定') : Text('次へ'),
              ),
            ],
          ),
        );
      },
      currentStep: _index,
      onStepCancel: () {
        if (_index == 3) {
          setState(() {
            _index = 0;
          });
        } else if (_index > 0) {
          setState(() {
            _index -= 1;
          });
        }
      },
      onStepContinue: () {
        if (_index <= 2) {
          setState(() {
            _index += 1;
          });
        }
      },
      onStepTapped: null,
      type: StepperType.horizontal,
      steps: <Step>[
        Step(
          title: const Image(
              image: AssetImage('images/moon.png'),
              height: 40,
              width: 50,
              fit: BoxFit.contain), //const Text('Step 1 title'),
          isActive: _index == 0,
          subtitle: Text('入力開始'),
          state: getStepState(0, _index),
          content: Container(
            height: getStepHeight(),
            child: const Text('Wizard形式の入力スタート'),
          ),
        ),
        Step(
          title: const Text('Step 2 title'),
          isActive: _index == 1,
          subtitle: const Text('ステップ2のサブタイトル'),
          state: getStep2State(_index),
          content: Container(
            height: getStepHeight(),
            child: Align(
              alignment: Alignment.center,
              child: Container(
                width: 200,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    const Text('10以下の数値を入力してください'),
                    TextField(
                        controller: _step2Input,
                        keyboardType: TextInputType.number,
                        inputFormatters: [
                          FilteringTextInputFormatter.digitsOnly
                        ]),
                  ],
                ),
              ),
            ),
          ),
        ),
        Step(
          title: const Text('Step 3 title'),
          isActive: _index == 2,
          state: getStepState(2, _index),
          content: Container(
            alignment: Alignment.center,
            height: getStepHeight(),
            child: getStep2State(_index) == StepState.error
                ? const Text('Step2で10以下の数値を入力してください。')
                : Text(_step2Input.text + "でよろしいですか?"),
          ),
        ),
        Step(
          title: const Text('Step 4 title'),
          isActive: _index == 3,
          state: getStepState(3, _index),
          content: Container(
            height: getStepHeight(),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                const Text('次の値が入力されました'),
                Center(
                  child:
                      Text(_step2Input.text, style: TextStyle(fontSize: 100)),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

  double getStepHeight() {
    return MediaQuery.of(context).size.height - 210;
  }

  StepState getStep2State(index) {
    if (index == 1) {
      return StepState.editing;
    } else if (index < 1) {
      return StepState.disabled;
    } else {
      return (_step2Input.text != "") && int.parse(_step2Input.text) <= 10
          ? StepState.complete
          : StepState.error;
    }
  }

  StepState getStepState(int stepNum, int index) {
    if (stepNum == index) {
      return StepState.editing;
    } else if (stepNum > index) {
      return StepState.disabled;
    } else {
      return StepState.complete;
    }
  }
}