対象読者
-
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
の構成MaterialApp
とScaffold
を使って基本的なアプリのレイアウトを構築。- アプリバーにタイトルを設定し、ボディ部分では
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です。
-
onDecrease
とonIncrease
- 戻る・次へボタンのコールバックを定義しています。
- 条件に応じて関数を返すか
null
を返すことで、ボタンが有効/無効となるように実装。- 例:
_activeIndex
が 0 より大きい場合にのみ「戻る」操作が可能。 - ボタンの
onPressed
にnull
を渡すと自動的に無効状態になるため、条件付きで関数を返すこの手法はシンプルで効果的です。(実装できるのか実験的でしたが、できました!)
- 例:
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回目はウィジェットが生成されていない状態で作成されるため、
renderBox
とoffset
がnullになります - ウィジェット生成後、2回目の
build
が動作します。そのときはrenderBox
とoffset
も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.steps
をasMap()
を使って、インデックスとともにループ処理。- 各ステップに対してウィジェットを生成しています。
-
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の場合に自動的にボタンが無効化されるため、このシンプルな手法で不要な操作を防止することができます。
参考
-
Creating a Custom Stepper in Flutter: A Comprehensive Guide
ステッパーのカスタマイズの元ネタ -
FlutterのWidgetの位置情報を取得する(RenderBox)
複数のWidgetを重ね合わせるために必要な、Widgetの位置情報の取得
まとめ
-
状態管理と GlobalKey の利用
_activeIndex
による現在のステップ管理と、各ステップの位置情報を GlobalKey で保持する設計により、アニメーションとインジケータの正確な位置決めが実現されています。
-
条件付きコールバックによるボタンの制御
- 戻る・次へボタンは、条件に応じてコールバックが
null
となり自動的に無効化されるため、ユーザーが無効な操作を行わないようになっています。
- 戻る・次へボタンは、条件に応じてコールバックが
-
アニメーションによる動的 UI 表現
AnimatedPositioned
とAnimatedContainer
を活用して、ステップの変更に伴う位置や色の変化をスムーズなアニメーションで表現しています。
このように各パーツが連携して動作することで、見た目にも操作感にも優れたカスタムステッパーが実現されています。
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,
],
),
);
}
}