【Flutter】アニメーション付きオリジナルステッパーを実装

  • 2025年2月11日
  • 2025年2月11日
  • 小物

対象読者

  • Flutter のウィジェットやアニメーションに興味がある方

  • 柔軟なボタン制御や状態管理を学びたい方

  • カスタム UI の構築とアニメーション実装に取り組みたい方

はじめに

Flutter では、Stepper ウィジェットを使って手順の進行状況を視覚的に示すことができますが、デフォルトのものではなく、自分好みにカスタマイズしたステッパーを作りたいというニーズもあるでしょう。
本記事では、カスタムステッパーの実装例を通して、特に以下の部分について詳しく解説します。

  • 特定のWidgetの位置取得し、他のWidgetと重ねる
  • アニメーション表示
  • 戻る・次へボタンのコールバックの実装(ボタンの有無効の切り替え)

それでは、コードの各ポイントを順を追って見ていきましょう。

実装

以下のコードは、Flutter でカスタムなステッパーを実装するサンプルです。
各ステップごとにアイコンとコンテンツを定義し、戻る・次へボタンでアクティブなステップを変更、さらにアニメーションを使って現在のステップ位置を視覚的に示しています。
以下、コード全体を各パーツ毎に分けて、わかりやすく解説していきます。

1. CustomStepperStep クラス

class CustomStepperStep {
  final IconData icon;
  final Widget content;

  const CustomStepperStep({
    required this.icon,
    required this.content,
  });
}
  • 目的
    各ステップの情報(アイコンと表示するコンテンツ)を保持するためのシンプルなデータクラスです。

  • プロパティ

    • icon: 各ステップのアイコンを指定するための IconData
    • content: 各ステップに対応するコンテンツ(ウィジェット)を指定します。

2. main 関数と MyApp ウィジェット

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Stepperのカスタム')),
        body: Column(
          children: [
            CustomStepper(
              steps: const [
                CustomStepperStep(
                  icon: Icons.shopping_cart,
                  content: Text('注文内容の確認'),
                ),
                CustomStepperStep(
                  icon: Icons.info,
                  content: Text('基本情報の入力'),
                ),
                CustomStepperStep(
                  icon: Icons.payment,
                  content: Text('支払い方法の確認'),
                ),
                CustomStepperStep(
                  icon: Icons.check_circle,
                  content: Text('注文前の確認'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
  • MyApp の構成
    • MaterialAppScaffold を使って基本的なアプリのレイアウトを構築。
    • アプリバーにタイトルを設定し、ボディ部分では CustomStepper を利用してステッパーを表示しています。
    • CustomStepper に渡す steps リストでは、各ステップのアイコンとコンテンツを定義しています。

3. CustomStepper ウィジェットと状態管理

3.1 CustomStepper の宣言

class CustomStepper extends StatefulWidget {
  const CustomStepper({
    super.key,
    required this.steps,
  });

  final List<CustomStepperStep> steps;

  @override
  State<CustomStepper> createState() => _CustomStepperState();
}
  • 役割
    • ステッパーの状態を持つウィジェットとして、内部で現在のアクティブなステップやアニメーション処理を管理します。
    • 外部から渡された steps リストをもとに各ステップの情報を取得します。

3.2 _CustomStepperState 内の状態変数と GlobalKey

class _CustomStepperState extends State<CustomStepper> {
  var _activeIndex = 0;

  late final _keys =
      List.generate(widget.steps.length, (_) => GlobalKey(), growable: false);
  • _activeIndex

    • 現在アクティブなステップのインデックスを保持する変数。初期値は 0(最初のステップ)です。
  • _keys

    • 各ステップのアイコンウィジェットに対して GlobalKey を生成するリストです。
    • 各キーを使ってウィジェットの位置(RenderBox)を取得し、アニメーション時の座標計算に利用します。

3.3 ゲッターとコールバックの定義

  Widget get content => widget.steps[_activeIndex].content;

  void Function()? get onDecrease =>
      0 < _activeIndex ? () => _changeIndex(_activeIndex - 1) : null;

  void Function()? get onIncrease => _activeIndex < widget.steps.length - 1
      ? () => _changeIndex(_activeIndex + 1)
      : null;
  • content

    • 現在のアクティブなステップに対応するコンテンツウィジェットを返すgetterです。
  • onDecreaseonIncrease

    • 戻る・次へボタンのコールバックを定義しています。
    • 条件に応じて関数を返すか null を返すことで、ボタンが有効/無効となるように実装。
      • 例: _activeIndex が 0 より大きい場合にのみ「戻る」操作が可能。
      • ボタンの onPressednull を渡すと自動的に無効状態になるため、条件付きで関数を返すこの手法はシンプルで効果的です。(実装できるのか実験的でしたが、できました!)

3.4 インデックス変更メソッド

  void _changeIndex(int index) {
    if (index < 0 || widget.steps.length <= index) {
      return;
    }
    setState(() {
      _activeIndex = index;
    });
  }
  • 役割
    • 引数として受け取った index が有効な範囲内かをチェックし、問題なければ setState を呼んでアクティブなステップを変更します。
    • この変更により、ウィジェット全体が再描画され、画面上の表示やアニメーションが更新されます。

4. build メソッド内の UI 構築とアニメーション処理

4.1 定数の定義

    const paddingWidth = 16.0;
    const animationDuration = Duration(milliseconds: 300);
  • paddingWidth
    • ウィジェット全体に適用するパディングの幅です。
  • animationDuration
    • アニメーションの継続時間を 300 ミリ秒に設定しています。

4.2 アクティブステップの位置取得

    final RenderBox? renderBox =
        _keys[_activeIndex].currentContext?.findRenderObject() as RenderBox?;
    final offset = renderBox?.localToGlobal(Offset.zero);
  • 役割
    • 現在アクティブなステップの GlobalKey を利用して、そのウィジェットの RenderBox(描画オブジェクト)を取得します。
    • localToGlobal メソッドでウィジェットの左上のグローバル座標(offset)を計算。
    • この位置情報は、後述の AnimatedPositioned でインジケータを正しい位置に配置するために使用されます。

4.3 ウィジェットのレイアウト構築

    return Padding(
      padding: const EdgeInsets.all(paddingWidth),
      child: Column(
        children: [
          // 戻る・次へボタンの Row
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              FilledButton(onPressed: onDecrease, child: const Text('戻る')),
              FilledButton(onPressed: onIncrease, child: const Text('次へ')),
            ],
          ),
          const SizedBox(height: 32),
  • パディング

    • 全体を Padding で囲むことで、内部に余白を持たせています。
  • ボタンの Row

    • 「戻る」と「次へ」ボタンを Row で横並びに配置。
    • 各ボタンの onPressed に先ほど定義したコールバック(onDecrease / onIncrease)を渡しています。
  • SizedBox

    • ボタンと次のウィジェットとの間にスペースを設けるために使用されています。

4.4 ステップインジケータのアニメーション表示

          Stack(
            children: [
              if (offset != null)
                AnimatedPositioned(
                  duration: animationDuration,
                  curve: Curves.easeInQuad,
                  left: offset.dx - paddingWidth,
                  child: Container(
                    width: 48,
                    height: 48,
                    decoration: BoxDecoration(
                        shape: BoxShape.circle, color: Colors.blue),
                  ),
                ),
  • Stack ウィジェット

    • 複数のウィジェットを重ねて表示するために使用。
    • 背景にアニメーションで移動するインジケータと、上に各ステップのアイコンが配置されます。
  • if (offset != null)

    • buildは2回動作します
    • 1回目はウィジェットが生成されていない状態で作成されるため、renderBoxoffsetがnullになります
    • ウィジェット生成後、2回目のbuildが動作します。そのときはrenderBoxoffsetもnullでないので、アイコンの後ろにインジケータ(青い丸)ができている、はず
  • AnimatedPositioned

    • 取得した offset をもとに、現在アクティブなステップの位置に合わせてインジケータの位置をスムーズに移動させます。
    • left プロパティに offset.dx - paddingWidth を設定し、パディング分の補正も行っています。

4.5 各ステップのアイコン表示(AnimatedContainer)

              Row(
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: widget.steps.asMap().entries.map((entry) {
                  final index = entry.key;
                  final step = entry.value;

                  return AnimatedContainer(
                    duration: animationDuration,
                    curve: Curves.easeInQuad,
                    key: _keys[index],
                    width: 48,
                    height: 48,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      color: index <= _activeIndex
                          ? Colors.blue
                          : Colors.grey.shade300,
                    ),
                    child: Icon(
                      step.icon,
                      color: index <= _activeIndex ? Colors.white : Colors.grey,
                      size: 32,
                    ),
                  );
                }).toList(),
              ),
  • Row と asMap() の利用

    • widget.stepsasMap() を使って、インデックスとともにループ処理。
    • 各ステップに対してウィジェットを生成しています。
  • AnimatedContainer

    • 各ステップのアイコンを囲むコンテナに対して、背景色やサイズの変化にアニメーションを適用。
    • GlobalKey (_keys[index]) を割り当てることで、後で位置情報を取得できるようにしています。
  • スタイルの切り替え

    • 現在の完了と表示中のステップ(index <= _activeIndex)の場合は青色背景と白いアイコン、そうでない場合はグレー系の表示となります。

4.6 コンテンツ表示

          const SizedBox(height: 16),
          widget.steps[_activeIndex].content,
  • 役割
    • 各ステップに対応するコンテンツを、現在のアクティブなステップの内容として表示します。
    • _activeIndex に基づいて、正しいコンテンツが描画されるため、ユーザーの操作に応じて内容が更新されます。

課題

ちなみに私の環境だと、スマホの向きを変えると、インジケータの位置がずれます。
OrientationBuilderを使ってトライしてみましたが、高さが無限扱いになり、常に縦向き扱いになりました。そのため、buildが走らず、ずれます。
実際の使用で対応が必要な場合は、高さに制限を付けてください。

Q&A

Q1. カスタムステッパー実装でGlobalKeyを利用するメリットは何ですか?
A1. GlobalKeyを使うことで、各ステップのウィジェット位置(RenderBox)を正確に取得でき、AnimatedPositionedなどのアニメーションでウィジェットの移動をスムーズに制御できます。これにより、動的なレイアウト変更やインジケータの正確な配置が実現し、UIの直感的な操作性が向上します。

**Q2. OrientationBuilderが正しく動作せず、常に縦向き扱いになる場合、どのように対処すればよいですか?
A2. OrientationBuilderが期待通りに動作しない場合、まず親ウィジェットに対して明示的なサイズ制約(ExpandedやSizedBoxなど)を与えて、無限の高さにならないようにします。また、システムレベルで画面向きが固定されていないかも確認することが重要です。これにより、正しいオリエンテーション情報が取得できるようになります。

Q3. 戻る・次へボタンの有効/無効を条件付きで切り替える仕組みはどのように実装していますか?
A3. 戻る・次へボタンのonPressedプロパティに、条件を満たす場合のみ実行する関数を返し、条件を満たさない場合はnullを返す実装を行っています。Flutterでは、onPressedがnullの場合に自動的にボタンが無効化されるため、このシンプルな手法で不要な操作を防止することができます。

参考

まとめ

  • 状態管理と GlobalKey の利用

    • _activeIndex による現在のステップ管理と、各ステップの位置情報を GlobalKey で保持する設計により、アニメーションとインジケータの正確な位置決めが実現されています。
  • 条件付きコールバックによるボタンの制御

    • 戻る・次へボタンは、条件に応じてコールバックが null となり自動的に無効化されるため、ユーザーが無効な操作を行わないようになっています。
  • アニメーションによる動的 UI 表現

    • AnimatedPositionedAnimatedContainer を活用して、ステップの変更に伴う位置や色の変化をスムーズなアニメーションで表現しています。

このように各パーツが連携して動作することで、見た目にも操作感にも優れたカスタムステッパーが実現されています。
Flutter のウィジェットやアニメーションの実装に悩んでいる方は、ぜひ今回の実装例を参考にしてみてください。コード全体の理解を深めることで、さらなるカスタマイズや応用が可能になります。

ソース(main.dartにコピペして動作確認用)

import 'package:flutter/material.dart';

class CustomStepperStep {
  final IconData icon;
  final Widget content;

  const CustomStepperStep({
    required this.icon,
    required this.content,
  });
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Stepperのカスタム')),
        body: Column(
          children: [
            CustomStepper(
              steps: const [
                CustomStepperStep(
                  icon: Icons.shopping_cart,
                  content: Text('注文内容の確認'),
                ),
                CustomStepperStep(
                  icon: Icons.info,
                  content: Text('基本情報の入力'),
                ),
                CustomStepperStep(
                  icon: Icons.payment,
                  content: Text('支払い方法の確認'),
                ),
                CustomStepperStep(
                  icon: Icons.check_circle,
                  content: Text('注文前の確認'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class CustomStepper extends StatefulWidget {
  const CustomStepper({
    super.key,
    required this.steps,
  });

  final List<CustomStepperStep> steps;

  @override
  State<CustomStepper> createState() => _CustomStepperState();
}

class _CustomStepperState extends State<CustomStepper> {
  var _activeIndex = 0;

  late final _keys =
      List.generate(widget.steps.length, (_) => GlobalKey(), growable: false);

  Widget get content => widget.steps[_activeIndex].content;

  void Function()? get onDecrease =>
      0 < _activeIndex ? () => _changeIndex(_activeIndex - 1) : null;

  void Function()? get onIncrease => _activeIndex < widget.steps.length - 1
      ? () => _changeIndex(_activeIndex + 1)
      : null;

  void _changeIndex(int index) {
    if (index < 0 || widget.steps.length <= index) {
      return;
    }
    setState(() {
      _activeIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    const paddingWidth = 16.0;
    const animationDuration = Duration(milliseconds: 300);

    final RenderBox? renderBox =
        _keys[_activeIndex].currentContext?.findRenderObject() as RenderBox?;
    final offset = renderBox?.localToGlobal(Offset.zero);
    print(MediaQuery.of(context).size.height);
    return Padding(
      padding: const EdgeInsets.all(paddingWidth),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              FilledButton(onPressed: onDecrease, child: const Text('戻る')),
              FilledButton(onPressed: onIncrease, child: const Text('次へ')),
            ],
          ),
          const SizedBox(height: 32),
          Stack(
            children: [
              if (offset != null)
                AnimatedPositioned(
                  duration: animationDuration,
                  curve: Curves.easeInQuad,
                  left: offset.dx - paddingWidth,
                  child: Container(
                    width: 48,
                    height: 48,
                    decoration: BoxDecoration(
                        shape: BoxShape.circle, color: Colors.blue),
                  ),
                ),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: widget.steps.asMap().entries.map((entry) {
                  final index = entry.key;
                  final step = entry.value;

                  return AnimatedContainer(
                    duration: animationDuration,
                    curve: Curves.easeInQuad,
                    key: _keys[index],
                    width: 48,
                    height: 48,
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      color: index <= _activeIndex
                          ? Colors.blue
                          : Colors.grey.shade300,
                    ),
                    child: Icon(
                      step.icon,
                      color: index <= _activeIndex ? Colors.white : Colors.grey,
                      size: 32,
                    ),
                  );
                }).toList(),
              ),
            ],
          ),
          const SizedBox(height: 16),
          widget.steps[_activeIndex].content,
        ],
      ),
    );
  }
}