FlutterのListViewとGridViewで一覧画面を作る(基本パターン) の次に、業務画面や設定画面で早めに必要になるのが確認 UI と通知 UI です。ここで止まりやすいのは API 名より、「削除前の確認」「保存完了の通知」「操作メニューの提示」を同じ感覚で書き始めてしまうところにあります。この記事では、showDialog ScaffoldMessenger.showSnackBar showModalBottomSheet を最小例で整理し、どの場面でどれを選ぶかの基準をまとめます。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 入門、基本レイアウト、一覧画面の基本までは終わった人
AlertDialogSnackBarBottomSheetを見たことはあるが、使い分けがまだ曖昧な人- 保存完了、削除確認、操作メニューを何で出すか毎回迷う人
この記事で到達する状態
- 削除前の確認を
showDialog<bool>で実装できる - 保存完了の通知を
ScaffoldMessenger.showSnackBarで出せる - 操作メニューを
showModalBottomSheet<String>で表示し、選択結果を受け取れる - Dialog / SnackBar / BottomSheet の使い分けを説明できる
非対象
- カスタムダイアログやフォーム付きダイアログ
- 永続 BottomSheet と
DraggableScrollableSheet - REST API 通信や状態管理ライブラリとの統合
- Material 3 のデザイン仕様の深掘り
3 つの UI は「見た目」ではなく「役割」で選びます。確認のために止める UI と、結果を軽く伝える UI は分けるのが基本です。そうしておくと、後で CRUD 画面やフォームへ進んだときも迷いにくくなります。
2. 先に 3 つの役割を分ける
最初に、Dialog / SnackBar / BottomSheet の役割を分けます。
| UI | 役割 | まず見るポイント | よくある場面 |
|---|---|---|---|
| Dialog | ユーザーに確認を求め、結果を受け取る | showDialog<T> / Navigator.pop(context, value) | 削除確認、未保存のまま閉じる確認 |
| SnackBar | 操作結果を短く知らせる | ScaffoldMessenger.showSnackBar | 保存完了、同期完了、簡単な取り消し |
| BottomSheet | 次の操作を選ばせる | showModalBottomSheet<T> | 編集、共有、削除などの操作メニュー |
判断を先に図で置くと、次の通りです。
flowchart TD
A[ユーザーへ何を求めたいか] --> B{確認が必要か}
B -->|はい| C[Dialog]
B -->|いいえ| D{結果を短く伝えるだけか}
D -->|はい| E[SnackBar]
D -->|いいえ| F{複数の操作候補を選ばせたいか}
F -->|はい| G[BottomSheet]
F -->|いいえ| H[画面内UIや別画面を検討]
「何を表示するか」より「ユーザーに何をしてほしいか」を先に決めます。
- 先に確認を取りたいなら Dialog
- 結果だけ伝えたいなら SnackBar
- 次の行動候補を並べたいなら BottomSheet
以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも動作確認が可能です。
3. showDialog は確認を止めて受け取る
削除や破棄のように、先に yes/no を確認したい場面では Dialog が向きます。showDialog<T> は Future<T?> を返すため、ユーザーがどちらを押したかを待ってから、次の処理を分けられます。
次の lib/main.dart は、確認ダイアログの結果を await で受け取り、画面表示へ反映する最小例です。返り値と mounted 確認の位置を見ると流れを掴みやすくなります。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const DeleteDialogPage(),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
);
}
}
class DeleteDialogPage extends StatefulWidget {
const DeleteDialogPage({super.key});
@override
State<DeleteDialogPage> createState() => _DeleteDialogPageState();
}
class _DeleteDialogPageState extends State<DeleteDialogPage> {
String status = 'まだ削除していません';
Future<void> confirmDelete() async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('商品を削除しますか'),
content: const Text('この操作を実行すると、一覧から対象データを外します。'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext, false);
},
child: const Text('キャンセル'),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext, true);
},
child: const Text('削除する'),
),
],
);
},
);
if (!mounted) {
return;
}
setState(() {
status = shouldDelete == true ? '商品を削除しました' : '削除をキャンセルしました';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dialog sample')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_outline, size: 64),
const SizedBox(height: 16),
Text(status, style: const TextStyle(fontSize: 18)),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: confirmDelete,
icon: const Icon(Icons.warning_amber_rounded),
label: const Text('削除確認を開く'),
),
],
),
),
),
);
}
}
コードのポイント
① showDialog<bool> の返り値を待って分岐する
final shouldDelete = await showDialog<bool>(
context: context,
Dialog は表示するだけでなく、ユーザーの選択結果を Future で返します。削除を進めるかどうかを次の処理で判断するため、まずここで結果を受け取る流れを押さえるのが重要です。
② ボタン側は Navigator.pop(..., value) で結果を返す
TextButton(
onPressed: () {
Navigator.pop(dialogContext, false);
},
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext, true);
},
),
どのボタンを押したかは、閉じるだけでは親画面に伝わりません。value を一緒に返しているので、呼び出し元が true と false をそのまま判定できます。
③ await のあとに mounted を確認してから setState する
if (!mounted) {
return;
}
setState(() {
status = shouldDelete == true ? '商品を削除しました' : '削除をキャンセルしました';
});
待機中に画面が破棄されている可能性があるため、await のあとで mounted を確認します。非同期処理のあとに state を触る基本形として、この位置関係を早めに見ておくと応用しやすくなります。
Dialog が向くのは、ユーザーの判断を先に確定したい場面です。削除、送信、画面を閉じる前の確認のように、ここで止める理由がある UI に絞ると見通しがよくなります。保存完了のような軽い通知まで Dialog にすると、毎回操作を止める画面になるため注意が必要です。
4. SnackBar は結果を短く伝える
保存完了や同期完了のように、ユーザーへ結果を伝えたいが操作は止めたくない場面では SnackBar が向きます。SnackBar は確認 UI ではなく通知 UI です。
次の lib/main.dart は、保存状態の更新と SnackBar 表示を同じ操作から起こす最小例です。通知の出し方と取り消しの戻し方を分けて見ると分かりやすくなります。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const SnackBarPage(),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
);
}
}
class SnackBarPage extends StatefulWidget {
const SnackBarPage({super.key});
@override
State<SnackBarPage> createState() => _SnackBarPageState();
}
class _SnackBarPageState extends State<SnackBarPage> {
bool saved = false;
void saveDraft() {
setState(() {
saved = true;
});
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: const Text('下書きを保存しました'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: '取り消す',
onPressed: () {
setState(() {
saved = false;
});
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('SnackBar sample')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
saved ? Icons.check_circle_outline : Icons.edit_note,
size: 64,
color: saved ? Colors.teal : Colors.grey,
),
const SizedBox(height: 16),
Text(
saved ? '保存済みです' : 'まだ保存していません',
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: saveDraft,
icon: const Icon(Icons.save_outlined),
label: const Text('下書きを保存する'),
),
],
),
),
),
);
}
}
コードのポイント
① 通知は ScaffoldMessenger から出す
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
ScaffoldMessenger を経由することで、現在の画面に対して SnackBar を表示できます。先に hideCurrentSnackBar() を呼んでいるので、ボタン連打時に通知が積み上がりにくい構成です。
② 保存状態の更新と通知表示を同じ操作にまとめる
void saveDraft() {
setState(() {
saved = true;
});
messenger.showSnackBar(
まず state を更新し、その直後に結果通知を出しています。UI の見た目と通知文が同じ操作を起点にしているため、何が起きたかを追いやすくなります。
③ 軽い取り消しだけを SnackBarAction に載せる
action: SnackBarAction(
label: '取り消す',
onPressed: () {
setState(() {
saved = false;
});
},
),
SnackBarAction は「今すぐ戻す」程度の軽い操作に向いています。重要な判断をここに載せず、通知と軽い巻き戻しに用途を絞るのが基本です。
SnackBar が向くのは「保存できた」「同期できた」「取り消すなら今です」といった短い結果通知です。ここでユーザーに必須判断を求めるのは向きません。削除確認を SnackBar に載せると、見逃したまま操作が進む可能性があるためです。
5. showModalBottomSheet は次の操作を選ばせる
1 つの確認ではなく、複数の行動候補を出したい場面では BottomSheet が向きます。詳細画面で「編集」「複製」「削除」をまとめて出すような UI が典型です。Dialog との違いは、yes/no の確認よりも「候補から選ばせる」ことにあります。
次の lib/main.dart は、BottomSheet で選択肢を返し、その結果だけを画面へ表示する例です。どこで選択値を返し、どこで無視するかを確認します。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const BottomSheetPage(),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
);
}
}
class BottomSheetPage extends StatefulWidget {
const BottomSheetPage({super.key});
@override
State<BottomSheetPage> createState() => _BottomSheetPageState();
}
class _BottomSheetPageState extends State<BottomSheetPage> {
String selectedAction = 'まだ操作を選んでいません';
Future<void> openActionSheet() async {
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('編集する'),
onTap: () {
Navigator.pop(sheetContext, '編集を選びました');
},
),
ListTile(
leading: const Icon(Icons.copy_all_outlined),
title: const Text('複製する'),
onTap: () {
Navigator.pop(sheetContext, '複製を選びました');
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('削除する'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () {
Navigator.pop(sheetContext, '削除を選びました');
},
),
],
),
);
},
);
if (!mounted || action == null) {
return;
}
setState(() {
selectedAction = action;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('BottomSheet sample')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.more_horiz, size: 64),
const SizedBox(height: 16),
Text(selectedAction, style: const TextStyle(fontSize: 18)),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: openActionSheet,
icon: const Icon(Icons.more_vert),
label: const Text('操作メニューを開く'),
),
],
),
),
),
);
}
}
コードのポイント
① showModalBottomSheet<String> も返り値を await で受ける
final action = await showModalBottomSheet<String>(
context: context,
BottomSheet も Dialog と同じく Future<T?> を返します。候補一覧を出す UI ですが、呼び出し元から見れば「何が選ばれたかを待つ処理」として読むことができます。
② 各選択肢は Navigator.pop(sheetContext, value) で結果を返す
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('編集する'),
onTap: () {
Navigator.pop(sheetContext, '編集を選びました');
},
),
ListTile(
leading: const Icon(Icons.copy_all_outlined),
title: const Text('複製する'),
onTap: () {
Navigator.pop(sheetContext, '複製を選びました');
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('削除する'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () {
Navigator.pop(sheetContext, '削除を選びました');
},
),
候補は複数ありますが、返す値の形はすべて同じです。どの行を押しても「閉じる」と「結果を返す」が同じ書き方になるため、行動メニューの追加にも広げやすい構成です。
③ null のときは何もしない分岐を先に置く
if (!mounted || action == null) {
return;
}
シート外をタップして閉じた場合は action が null になります。選択されなかったケースを先に除外しておくと、下の setState は「何かが選ばれた場合だけ」と読み取れます。
BottomSheet は「確認」より「候補提示」に向いています。削除そのものを選ばせるのは構いませんが、危険操作の最終確認まで BottomSheet だけで済ませると、意図しないタップで進みやすくなるため注意が必要です。削除を選んだあとに、本当に確定するかを Dialog で聞く構成のほうが扱いやすい場面もあります。
6. 迷ったときの使い分け
よくある場面ごとに選ぶ理由を整理します。
| 場面 | 向く UI | 理由 |
|---|---|---|
| 商品を削除する前に確認したい | Dialog | 先に yes/no を確定したいから |
| 下書きを保存できたことを知らせたい | SnackBar | 結果だけ伝えれば十分で、操作を止める必要がないから |
| 詳細画面から編集・複製・削除を選ばせたい | BottomSheet | 複数の行動候補を縦に並べて選ばせたいから |
| 通信に失敗したので再試行を促したい | SnackBar または画面内UI | 軽い再試行なら SnackBar、内容説明が長いなら画面内UIのほうが向く |
| 画面を閉じる前に未保存変更を確認したい | Dialog | ここで止めないと意図せず破棄されるから |
短くまとめるなら、判断基準は次の 1 文です。
確認のためにいったん止めたいなら Dialog、結果を短く伝えるだけなら SnackBar、次の操作候補を並べるなら BottomSheet です。
7. 最初に詰まりやすい点
最後に、初学者が止まりやすい点を 3 つだけ整理します。
- Dialog と BottomSheet はどちらも
Future<T?>を返すため、結果を使いたいならawaitが必要です。 - SnackBar は
ScaffoldMessengerから出します。Scaffold配下の画面通知として扱うと整理しやすくなります。 - Dialog と BottomSheet は、外側タップや戻る操作で閉じると
nullになることがあります。true/falseや文字列だけを前提にせず、nullの分岐を残したほうが安全です。
ここまでを押さえると、確認 UI と通知 UI を混ぜずに組み始められます。次にフォームや CRUD 画面へ進んだときも、「この操作は止めるべきか、知らせるだけでよいか」を先に決めてから Widget を選べるはずです。