关于 ui-states-and-feedback
面向 Flutter 开发的界面状态技能,指导如何呈现和播报加载、空、错误与内容状态,并处理刷新、失败及操作反馈。
主要功能
- 01在单一位置解析屏幕状态
- 02区分空、筛选无结果和错误
- 03使用延迟且匹配布局的骨架屏
- 04选择行内提示、Snackbar、横幅或对话框
- 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 负责。本技能仅关注它们的呈现与播报方式。
不可妥协的规则
- 在一个位置解析一种状态。 屏幕应准确呈现以下状态之一:加载中/空状态/错误/内容,由针对
AsyncValue或密封视图状态的单个switch选择。绝不要使用isLoading、error、items这三个布尔值。 原因:分散的标志会让不可能的状态变得可表示——既在加载中 又 出错、既为空 又 在加载中——而这些组合最终都会被渲染出来。 - 由 ViewModel 决定状态,由 View 渲染状态。 不要在 widget 树中间用
FutureBuilder或StreamBuilder包装仓库调用。 原因:散布在整棵树中的状态无法在不泵入 widgets 的情况下测试,而且每次重建都会重新触发 future。参见state-management-riverpod、widget-composition。 - 空状态、筛选后为空和错误是三种不同的屏幕。 “你还没有添加任何内容”(提供主要操作)、“没有内容符合 此筛选条件”(提供清除筛选条件的选项)和“无法加载此内容”(提供重试选项)分别回答了用户的三个不同问题。原因:加载失败时显示“没有项目”,会让用户以为自己的数据不见了。
- 绝不要渲染异常。 不要在界面中显示
e.toString()、堆栈跟踪或故障类名称。将类型化Failure.code映射到 ARB 中的本地化消息。原因:原始异常无法翻译、无法据此采取行动,而且经常会泄露路径或 ID。 - 每种错误状态都要提供下一步操作。 重试(
ref.invalidate(provider))、返回或更改输入——而且重试必须保留滚动位置、筛选条件和已输入的文本。原因:无路可走的错误屏幕会把暂时性故障变成卸载应用的理由。 - 刷新时绝不清空屏幕。 重新加载时保留之前的内容,并显示不显眼的指示器(
AsyncValue.when的skipLoadingOnRefresh默认值,或valueOrNull+isRefreshing)。原因:用转圈指示器替换列表会让用户失去所在位置,看起来也像数据丢失。 - 加载时显示延迟出现、形状与结果一致的骨架屏,而不是居中显示转圈指示器。前约 150–300 毫秒内不显示,以免快速加载时闪现;并让骨架屏与最终布局一致,这样内容出现时就不会跳动。原因:闪现和重排是两种不同的感知缺陷,而且都可以轻松避免。(本技能中的所有时长都是行为阈值,不是要硬编码的值——具体数值应归入
design-system-structure中的时长 token。) - 执行中的操作应在其自身控件上显示进度,而不是覆盖整个应用。 禁用按钮,在按钮中显示进度,同时让其他部分保持可交互。只有继续操作会破坏状态时,才使用全屏模态遮罩。原因:用阻断式遮罩覆盖一个 200 毫秒的写入操作,是最常见的自造“应用卡死”问题。
- 按层级选择呈现形式:行内 < snackbar < banner < 对话框。 选择可行的最低一级(见下表)。只有必须先作出决定才能继续操作时,才使用模态界面。原因:层级每升一级,用户就少一分控制权;而且只有对话框会阻止用户操作。
- 用户必须采取行动的信息绝不能只通过 snackbar 呈现。 snackbar 会自动消失,用户可能完全错过,屏幕阅读器也可能无法读到。对于十秒后仍然重要的信息,应使用 banner 或行内状态。
- 优先使用软删除 + 撤销,而不是确认对话框。 只有确实不可逆的操作才要求确认,并明确说明后果(“永久删除 12 条笔记”),而不是询问“确定吗?”,同时设置
barrierDismissible: false。原因:对 99% 确认无误的用户来说,撤销更快;对 1% 误操作的用户来说,也更安全。软删除写入路径参见error-handling-typed-results。 showDialog/showModalBottomSheet返回Future<T?>——null表示已关闭,且应作为独立结果处理。 对密封结果使用switch,并显式处理关闭情况;绝不要写?? true。原因:?? true会悄无声息地把点击对话框外部转换为确认删除。- 在
await之前捕获ScaffoldMessenger/Navigator,并在之后检查mounted。 然后显示 snackbar。原因:写入操作返回时,BuildContext可能已经不存在——这正是use_build_context_synchronously漏洞(async-safety)。 - 播报只有视觉上可见的内容。 出现的结果(已保存、已删除、已重试、已筛选)应通过
liveRegion: true或SemanticsService.announce播报;当MediaQuery.accessibleNavigationOf(context)为true时,应延长自动消失时间或取消自动消失。原因:对正在听屏幕阅读器朗读内容的用户来说,4 秒的 snackbar 是不可见的。播报效果因平台而异,重要信息绝不能只通过播报传达。
四种状态——一个判定点
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。
提示方式分级
| 提示方式 | 持续时间 | 适用场景 | 绝不适用于 |
|---|---|---|---|
| 行内提示(位于相关字段/区域中) | 直到问题解决 | 验证、单项失败、区域为空 | 全局情况 |
| SnackBar | 几秒,自动消失 | 操作已完成并提供撤销;非关键且已恢复的故障 | 用户必须采取行动的情况,或用户希望再次查看的内容 |
横幅(showMaterialBanner) | 直到关闭 | 持续存在的应用级情况:离线、数据过期、功能降级 | 短暂确认 |
| 对话框/模态底部面板 | 解决前阻止操作 | 必须立即作出的决定、不可逆操作的确认 | 显示信息、报告结果,或任何可以行内呈现的内容 |
列表中的单项失败属于行内行状态,而不是屏幕状态——一行同步失败,不应替换其余四十九行。
写入后的反馈
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()`
对话框标题应写明后果和数量;确认按钮应使用动词(“永久删除”),而不是“确定”。用户点击前会先看破坏性操作动词和数量——“确定吗?”没有提供任何信息。
反模式
- 将
isLoading、errorMessage和items作为三个字段——这样会让不可能的状态也能被表示,并最终被渲染出来。 - 在
build()中通过FutureBuilder调用存储库——每次重建都会重新触发,而且不经过 widget pump 就无法测试。 - 刷新时用居中的转圈指示器替换已加载的内容——会丢失滚动位置,让用户以为数据丢失了。
- 不延迟就显示转圈指示器——每次快速加载都会闪一下;这个闪烁本身就是缺陷。
- 骨架屏与最终布局不匹配——内容显示后,整个布局都会跳动,造成卡顿感。
- 加载失败时显示“没有项目”——会让用户以为数据已经丢失。
- 在界面中显示
Text('${e}')或堆栈跟踪——无法翻译、无法采取行动,还会泄露信息。 - 错误状态没有任何操作——临时故障会变成死胡同。
- 用对话框报告结果(“已保存!”)——只是为了传达 SnackBar 就能说、甚至无需说明的内容,便阻止用户继续操作。
- 用 SnackBar 显示关键警告——它会自动消失,可能被错过,也可能永远无法被屏幕阅读器读到。
- 将对话框结果设为
?? true——点击外部区域就会变成确认销毁。 await后调用ScaffoldMessenger.of(context),却没有mounted检查——会抛出异常或指向已失效的树(async-safety)。- 为快速的本地写入设置全屏阻塞遮罩——应用看起来会像是卡住了。
- 每次删除都弹出确认对话框,而不是提供撤销——这会惩罚 99% 本来就想删除的用户,而且并不能真正保护那 1% 的用户。
完成定义
- 屏幕通过单个
switch/when恰好解析出 loading / empty / error / content 中的一种状态;不使用三个布尔值;不在FutureBuilder中调用 repository。 - 空状态、筛选后无结果状态和错误状态是不同的视图;空状态包含主要操作,筛选后无结果状态提供清除筛选条件的选项。
- 错误文本来自通过 ARB 映射的类型化
Failure.code;不显示异常、类名或堆栈跟踪。 - 每种错误状态都有重试/后续操作,且会保留滚动位置、筛选条件和输入内容。
- 刷新时保留屏幕上的现有内容;延迟显示的加载骨架屏应与最终布局一致。
- 正在执行的操作在其自身控件上显示进度;除非有明确说明的理由,否则不使用覆盖整个应用的屏障。
- 每条消息都使用足以满足需求的最低层级表面;任何可执行操作都不能只出现在 snackbar 中;每项操作的失败都应显示在其所在行内。
- 破坏性操作使用软删除 + 撤销,或使用
barrierDismissible: false对话框,并说明后果和数量。 - 将关闭对话框作为独立结果处理;不使用
?? <肯定选项>。 - 每次
await前捕获ScaffoldMessenger/Navigator;之后检查mounted;显示新 snackbar 前先清除已有 snackbar。 - 状态变化会通过
liveRegion/SemanticsService.announce播报,并且在accessibleNavigation下延长自动关闭时间。 - Widget 测试覆盖上述四种状态,以及关闭和重试。
相关技能
state-management-riverpod— 状态的判定位置,以及AsyncValue的所有权。error-handling-typed-results— 此处呈现的Result/Failure值,以及软删除/撤销写入路径。async-safety— 在await前捕获context,并在之后检查mounted。widget-composition— 无状态逻辑的视图、提取的const状态组件,以及build()中不包含逻辑。accessibility-as-code— 语义、实时区域,以及从MediaQuery读取的 a11y 标志。motion-and-haptics— 反馈的感官部分:进入这些状态时的过渡动画、每次已提交操作的一次触觉反馈,以及减少动态效果的路径。i18n-rtl-l10n— ARB 中的所有消息,包括失败代码消息。app-startup-and-bootstrap— 本技能未涵盖的首帧/启动画面路径。widget-golden-and-a11y-testing— 为上述四种状态执行 pump 并进行断言。design-system-structure— 骨架屏、横幅和空状态组件所在的位置。
参考资料
- Material 3 — Snackbar:https://m3.material.io/components/snackbar/guidelines
- Material 3 — Banner(Material 2 规范,仍是 Flutter Widget 的依据):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/
评论
支持 Markdown