ui-states-and-feedback について
Flutter の画面状態を扱う開発スキルです。読み込み、空、エラー、コンテンツの表示と通知を整理し、更新や失敗、操作後のフィードバック方法を示します。
主な機能
- 01画面状態を一か所で解決
- 02空・絞り込み結果なし・エラーを区別
- 03遅延表示しレイアウトに合うスケルトン
- 04インライン・スナックバー・バナー・ダイアログの選択
- 05状態変化のアクセシブルな通知
活用シーン
- 01画面の読み込み状態や失敗状態の設計
- 02スクロール位置や絞り込みを保つ再試行
- 03書き込みや削除後のフィードバック選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill ui-states-and-feedbackインストール後、Claude Code で/ui-states-and-feedbackを入力して読み込まれたか確認します。
同梱ファイルexamples/async_state_view.dart
詳細
UI の状態とフィードバック
画面のコードの大半は正常系ですが、欠陥の大半はそうではありません。このスキルでは、データがまだない場合、データがまったくない場合、フィルターに一致するデータがない場合、エラーが発生した場合、または書き込み処理の直後に画面に表示される内容と、それぞれで使用できる表示領域を定めます。初回フレーム/コールドスタートの処理は
app-startup-and-bootstrap に、エラーの値は error-handling-typed-results に属します。
このスキルでは、それらの表示方法と通知方法のみを扱います。
必須ルール
- 状態は 1 つにし、1 か所で解決します。 画面では、
AsyncValueまたは sealed view state に対する単一のswitchで選択した、読み込み中/空/エラー/コンテンツのいずれか 1 つだけを表示します。isLoading、error、itemsのような真偽値を 3 つ並べるのは避けてください。 理由:フラグを個別に持つと、読み込み中 かつ エラー、空 かつ 読み込み中といった、あり得ない状態も表現できてしまい、いずれその組み合わせが画面に表示されます。 - 状態は ViewModel が決め、View が描画します。 ウィジェットツリーの途中で、リポジトリ呼び出しを
FutureBuilderやStreamBuilderで囲まないでください。 理由:ツリー内に状態が散在すると、ウィジェットを pump しないとテストできず、再ビルドのたびに future が再実行されます。state-management-riverpod、widget-compositionを参照してください。 - 空、フィルター適用後の空、エラーはそれぞれ別の画面です。 「まだ何も追加されていません」(主要な操作を提示)、「このフィルターに一致するものはありません」(フィルターの解除を提示)、「読み込めませんでした」(再試行を提示)は、ユーザーの異なる 3 つの疑問に答えます。 理由:読み込みに失敗したときに「項目がありません」と表示すると、ユーザーはデータが消えたと思ってしまいます。
- 例外を画面に表示しないでください。 UI で
e.toString()、スタックトレース、失敗クラス名を表示してはいけません。型付きのFailure.codeを ARB 経由でローカライズされたメッセージに変換します。 理由:生の例外は翻訳できず、対処にもつながらず、パスや ID が漏れることもよくあります。 - すべてのエラー状態に次の操作を用意します。 再試行(
ref.invalidate(provider))、戻る、入力を変更するなどです。再試行時はスクロール位置、フィルター、入力済みテキストを保持してください。 理由:行き止まりのエラー画面は、一時的な失敗をアンインストールのきっかけにしてしまいます。 - 更新中に画面を空白にしないでください。 再読み込み中も以前のコンテンツを表示したまま、控えめなインジケーターを表示します(
AsyncValue.whenのskipLoadingOnRefreshのデフォルト値、またはvalueOrNullとisRefreshing)。 理由:リストをスピナーに置き換えると、ユーザーが見ていた位置が失われ、データ消失のように感じられます。 - 読み込み中は、結果の形に合わせたスケルトンを遅延表示し、中央配置のスピナーは使いません。 高速な読み込みで表示が一瞬ちらつかないよう、最初の約 150~300 ms は表示を抑え、コンテンツが表示されたときに何も飛び跳ねないよう、最終的なレイアウトに合わせます。
理由:一瞬のちらつきとレイアウトの再配置は、別々に感じられる不具合です。どちらも簡単に回避できます。(このスキルで示すすべての時間は動作上のしきい値であり、ハードコードする値ではありません。数値自体は
design-system-structureの duration tokens に含めます。) - 処理中の操作は、アプリ全体ではなく、その操作自身のコントロールに進行状況を表示します。 ボタンを無効にして、ボタン内に進行状況を表示し、ほかの部分は操作できる状態に保ちます。全画面のモーダルバリアが必要なのは、処理を続けると状態が破損する場合に限ります。 理由:200 ms の書き込み処理中に画面全体をブロックすることは、「アプリがフリーズした」という状況を最もよく引き起こす、自ら招いた問題です。
- 表示方法は段階に沿って選びます:インライン < スナックバー < バナー < ダイアログ。 機能する範囲で、最も控えめな方法を選びます(下の表)。モーダルを使うのは、ユーザーが続行する前に決定を確定する必要がある場合に限ります。 理由:段階を上がるごとにユーザーの操作の自由が制限され、ユーザーの操作もブロックするのはダイアログだけです。
- ユーザーが対応する必要のある情報を、スナックバーだけで伝えてはいけません。 スナックバーは自動的に閉じ、見逃されることもあり、スクリーンリーダーが読み上げない場合もあります。10 秒後も重要な情報には、バナーまたはインライン状態を使います。
- 確認ダイアログより、ソフト削除 + Undo を優先します。 本当に取り消せない操作だけ確認します。その場合、「よろしいですか?」と聞くのではなく、「12 個のノートを完全に削除します」のように結果を明示し、
barrierDismissible: falseにします。 理由:Undo は、意図どおりに操作した 99% のユーザーにはより速く、意図しない操作をした 1% のユーザーにはより安全です。ソフト削除の書き込みパスについては、error-handling-typed-resultsを参照してください。 showDialog/showModalBottomSheetはFuture<T?>を返します。nullは閉じられたことを意味し、それ自体が 1 つの結果です。sealed result をswitchで判定し、閉じられた場合も明示的に処理してください。?? trueは使わないでください。 理由:?? trueを使うと、ダイアログの外側をタップして閉じた操作が、確認済みの削除として暗黙に扱われてしまいます。awaitの前にScaffoldMessenger/Navigatorを取得し、その後mountedを確認します。 そのうえでスナックバーを表示します。 理由:書き込みの応答が返る前にBuildContextが破棄される可能性があります。これはuse_build_context_synchronouslyのまさにこの問題です(async-safety)。- 目でしか認識できない変化を通知します。 結果が表示されたとき(保存、削除、再試行、フィルター適用)は、
liveRegion: trueまたはSemanticsService.announceで通知します。また、MediaQuery.accessibleNavigationOf(context)が true の場合は、自動的に閉じるまでの時間を延ばすか、自動で閉じないようにします。 理由:4 秒で消えるスナックバーは、スクリーンリーダーを使って文の途中にいるユーザーには見えません。通知はプラットフォームごとに最善努力で行います。重要な情報を伝える唯一の手段にしてはいけません。
4 つの状態 — 解決ポイントは 1 つ
class NotesScreen extends ConsumerWidget {
const NotesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final notes = ref.watch(notesProvider);
// ONE switch. skipLoadingOnRefresh (default true) keeps the list on screen
// while it reloads — the refresh path never falls into `loading`.
return notes.when(
loading: () => const NotesSkeleton(), // shaped like the list, delayed
error: (error, _) => ErrorStateView(
// A typed Failure's stable code → a localized string. Never e.toString().
message: l10n.failureMessage(error is Failure ? error.code : 'error.unknown'),
onRetry: () => ref.invalidate(notesProvider),
),
data: (notes) => notes.isEmpty
? EmptyStateView( // "nothing yet" ≠ "no matches"
title: l10n.notesEmptyTitle,
action: l10n.notesEmptyCreate,
onAction: () => ref.read(notesProvider.notifier).createDraft(),
)
: NotesListView(notes: notes),
);
}
}
フィルターのある画面では、空 より先に フィルター適用後に空 の状態を判定します。フィルターが有効かどうかは ViewModel が把握しているため、リストが少ない理由を View が推測する必要はありません。遅延表示のスケルトンと部分的な失敗時の行の状態も含む完全なファイルは、examples/async_state_view.dart にあります。
表示領域の段階
| 表示領域 | 表示期間 | 用途 | 使用しない用途 |
|---|---|---|---|
| インライン(該当するフィールド/領域内) | 解決するまで | 入力の検証、項目ごとの失敗、空の領域 | 全体に関わる状態 |
| スナックバー | 数秒間、自動で閉じます | 完了した操作とその取り消し、回復済みの重要度が低い失敗 | ユーザーが対応する必要がある情報、または読み返したい情報 |
バナー(showMaterialBanner) | 閉じるまで | オフライン、期限切れデータ、縮退モードなど、継続するアプリ全体の状態 | 一時的な確認 |
| ダイアログ/モーダルシート | 解決するまで操作をブロックします | 今すぐ 決定する必要があること、取り消せない操作の確認 | 情報の表示、結果の報告、インライン表示できるもの |
リスト内の項目ごとの失敗は、画面の状態ではなくインラインの行の状態です。同期に失敗した 1 行が、ほかの 49 行に取って代わってはいけません。
書き込み後のフィードバック
Future<void> _delete(BuildContext context, WidgetRef ref, NoteId id) async {
// Capture BEFORE the await — the context may be gone when the write returns.
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final result = await ref.read(notesProvider.notifier).softDelete(id);
if (!context.mounted) return;
switch (result) {
case Ok():
messenger.clearSnackBars(); // don't queue behind an older one
messenger.showSnackBar(SnackBar(
content: Text(l10n.noteDeleted),
// Undo instead of a pre-emptive "Are you sure?" dialog.
action: SnackBarAction(
label: l10n.undo,
onPressed: () => ref.read(notesProvider.notifier).restore(id),
),
// Auto-dismiss is a barrier for screen-reader and switch users.
duration: MediaQuery.accessibleNavigationOf(context)
? const Duration(seconds: 30)
: const Duration(seconds: 6),
));
case Err(:final failure):
// A failure the user must act on is NOT a snackbar.
messenger.showMaterialBanner(deleteFailedBanner(l10n, failure.code));
}
}
SnackBarAction は、ソフト 削除を取り消すための操作です。行には削除済みのフラグが設定されるだけで、実際の削除は後で行われます(error-handling-typed-results)。レコードを実際には復元できない取り消し機能は、取り消し機能がないよりも悪いものです。
取り消せない操作の確認
// Sealed outcome — dismissal is a real case, not a missing value.
sealed class ConfirmOutcome {}
final class ConfirmAccepted implements ConfirmOutcome {}
final class ConfirmDeclined implements ConfirmOutcome {}
final class ConfirmDismissed implements ConfirmOutcome {} // tapped outside / back
final outcome = await showDialog<ConfirmOutcome>(
context: context,
barrierDismissible: false, // a decision that must resolve does not evaporate
builder: (_) => PermanentDeleteDialog(count: selected.length),
) ??
ConfirmDismissed(); // null == dismissed — NEVER `?? ConfirmAccepted()`
ダイアログのタイトルでは結果と件数を明示し、確認ボタンには動詞(「完全に削除」など)を使います。「OK」は使いません。ユーザーがタップする前に読むのは、破壊的な操作を示す動詞と件数です。「本当によろしいですか?」では何も伝わりません。
アンチパターン
isLoading、errorMessage、itemsの 3 つをフィールドとして持つ — 表現できてしまう不可能な状態が、いずれ画面に表示されます。build()内でリポジトリを呼び出すFutureBuilder— ビルドのたびに再実行され、ウィジェットをポンプしないとテストできません。- 更新時に読み込み済みコンテンツを中央のスピナーに置き換える — スクロール位置が失われ、データが消えたように見えます。
- 遅延のないスピナー — 読み込みが速いと毎回点滅し、その点滅自体が不具合になります。
- 最終レイアウトと一致しないスケルトン — コンテンツが表示されると全体が跳ね、動きがぎこちなく見えます。
- 読み込み失敗時に「項目がありません」と表示する — データが消えたとユーザーに伝えてしまいます。
- UI に
Text('${e}')やスタックトレースを表示する — 翻訳できず、対応方法も示されず、内部情報が漏れます。 - 操作手段のないエラー状態 — 一時的な失敗が行き止まりになります。
- 結果を報告するためにダイアログを使う(「保存しました!」など) — スナックバー、あるいは何も表示しなくても伝えられることを知らせるために、ユーザーの操作をブロックします。
- 重大な警告をスナックバーで表示する — 自動で閉じられ、見逃される可能性があり、スクリーンリーダーに届かないこともあります。
- ダイアログの結果に
?? trueを使う — 外側をタップしただけで、削除が確定します。 awaitの後にmountedを確認せずScaffoldMessenger.of(context)を使う — 例外が発生するか、破棄済みのツリーを対象にします(async-safety)。- 高速なローカル書き込みに全画面のブロック用バリアを表示する — アプリがフリーズしたように見えます。
- 取り消し機能の代わりに、削除のたびに確認ダイアログを表示する — 削除するつもりだった 99% のユーザーに負担をかけるうえ、残り 1% を実際に守れるわけでもありません。
完了の定義
- 画面は単一の
switch/whenで、loading/empty/error/contentのいずれか1つだけを解決します。boolの3値判定は使わず、リポジトリ呼び出しをFutureBuilderでラップしません。 - empty、filtered-empty、errorはそれぞれ異なるビューにします。emptyには主なアクションを含め、filtered-emptyではフィルターをクリアできるようにします。
- エラーテキストは、型付きの
Failure.codeをARB経由でマッピングして表示します。例外、クラス名、スタックトレースは表示しません。 - すべてのエラー状態に、スクロール位置、フィルター、入力を保持する再試行/次のアクションを用意します。
- 更新中も以前のコンテンツを画面に表示し続けます。ローディングが遅延してから表示するスケルトンは、最終的なレイアウトに合わせます。
- 実行中のアクションでは、そのアクションを行うコントロールに進捗を表示します。明確な理由がない限り、アプリ全体を覆うバリアは使いません。
- 各メッセージには、要件を満たす最も控えめな表示領域を使用します。スナックバーだけに操作可能な内容を置かず、項目ごとの失敗はその行にインラインで表示します。
- 破壊的なアクションには、ソフト削除+Undo、または結果と件数を明示する
barrierDismissible: falseのダイアログを使用します。 - ダイアログの閉じる操作は独立した結果として処理します。
?? <affirmative>は使いません。 - すべての
awaitの前にScaffoldMessenger/Navigatorを取得し、その後はmountedを確認します。新しいスナックバーを表示する前に、既存のスナックバーをクリアします。 - 状態の変化を(
liveRegion/SemanticsService.announceで)通知し、accessibleNavigationが有効な場合は自動で閉じるまでの時間を延長します。 - ウィジェットテストで、4つすべての状態に加えて、閉じる操作と再試行をカバーします。
関連スキル
state-management-riverpod— 状態の決定場所とAsyncValueの所有者。error-handling-typed-results— ここで表示するResult/Failure値と、ソフト削除/Undoの書き込み経路。async-safety—awaitの前にcontextを取得し、その後にmountedを確認する方法。widget-composition— ロジックを持たないビュー、抽出したconst状態ウィジェット、build()内にロジックを置かない方法。accessibility-as-code— セマンティクス、ライブリージョン、MediaQueryから読み取るa11yフラグ。motion-and-haptics— フィードバックの感覚的な側面。これらの各状態への遷移でアニメーションさせる内容、確定したアクションごとに1回の触覚フィードバック、動きを減らす場合の経路。i18n-rtl-l10n— 失敗コードのメッセージを含め、すべてのメッセージをARBに記述する方法。app-startup-and-bootstrap— このスキルでは扱わない初回フレーム/スプラッシュ画面の経路。widget-golden-and-a11y-testing— 4つの状態それぞれを表示して検証する方法。design-system-structure— スケルトン、バナー、空状態のコンポーネントの配置場所。
参考資料
- Material 3 — スナックバー:https://m3.material.io/components/snackbar/guidelines
- Material 3 — バナー(Material 2の仕様ですが、Flutterウィジェットの基礎として現在も使われています):https://m2.material.io/components/banners
- Material 3 — ダイアログ:https://m3.material.io/components/dialogs/guidelines
- Flutter —
ScaffoldMessenger:https://api.flutter.dev/flutter/material/ScaffoldMessenger-class.html - Flutter —
SemanticsService.announce:https://api.flutter.dev/flutter/semantics/SemanticsService/announce.html - Riverpod —
AsyncValue(when、skipLoadingOnRefresh、valueOrNull):https://riverpod.dev/docs/essentials/async_initialization - Nielsen Norman Group — 進捗インジケーターと応答時間:https://www.nngroup.com/articles/response-times-3-important-limits/