mcpField
U
ui-states-and-feedback

ui-states-and-feedback

github.com/zakariaf设计与 UI63170 次安装5 周前更新
代码仓库

关于 ui-states-and-feedback

面向 Flutter 开发的界面状态技能,指导如何呈现和播报加载、空、错误与内容状态,并处理刷新、失败及操作反馈。

主要功能

  1. 01在单一位置解析屏幕状态
  2. 02区分空、筛选无结果和错误
  3. 03使用延迟且匹配布局的骨架屏
  4. 04选择行内提示、Snackbar、横幅或对话框
  5. 05无障碍播报状态变化

适用场景

  1. 01设计屏幕的加载与失败状态
  2. 02提供重试且保留筛选条件和滚动位置
  3. 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. 在一个位置解析一种状态。 屏幕应准确呈现以下状态之一:加载中/空状态/错误/内容,由针对 AsyncValue 或密封视图状态的单个 switch 选择。绝不要使用 isLoading、error、items 这三个布尔值。 原因:分散的标志会让不可能的状态变得可表示——既在加载中 又 出错、既为空 又 在加载中——而这些组合最终都会被渲染出来。
  2. 由 ViewModel 决定状态,由 View 渲染状态。 不要在 widget 树中间用 FutureBuilder 或 StreamBuilder 包装仓库调用。 原因:散布在整棵树中的状态无法在不泵入 widgets 的情况下测试,而且每次重建都会重新触发 future。参见 state-management-riverpod、widget-composition。
  3. 空状态、筛选后为空和错误是三种不同的屏幕。 “你还没有添加任何内容”(提供主要操作)、“没有内容符合 此筛选条件”(提供清除筛选条件的选项)和“无法加载此内容”(提供重试选项)分别回答了用户的三个不同问题。原因:加载失败时显示“没有项目”,会让用户以为自己的数据不见了。
  4. 绝不要渲染异常。 不要在界面中显示 e.toString()、堆栈跟踪或故障类名称。将类型化 Failure.code 映射到 ARB 中的本地化消息。原因:原始异常无法翻译、无法据此采取行动,而且经常会泄露路径或 ID。
  5. 每种错误状态都要提供下一步操作。 重试(ref.invalidate(provider))、返回或更改输入——而且重试必须保留滚动位置、筛选条件和已输入的文本。原因:无路可走的错误屏幕会把暂时性故障变成卸载应用的理由。
  6. 刷新时绝不清空屏幕。 重新加载时保留之前的内容,并显示不显眼的指示器(AsyncValue.when 的 skipLoadingOnRefresh 默认值,或 valueOrNull + isRefreshing)。原因:用转圈指示器替换列表会让用户失去所在位置,看起来也像数据丢失。
  7. 加载时显示延迟出现、形状与结果一致的骨架屏,而不是居中显示转圈指示器。前约 150–300 毫秒内不显示,以免快速加载时闪现;并让骨架屏与最终布局一致,这样内容出现时就不会跳动。原因:闪现和重排是两种不同的感知缺陷,而且都可以轻松避免。(本技能中的所有时长都是行为阈值,不是要硬编码的值——具体数值应归入 design-system-structure 中的时长 token。)
  8. 执行中的操作应在其自身控件上显示进度,而不是覆盖整个应用。 禁用按钮,在按钮中显示进度,同时让其他部分保持可交互。只有继续操作会破坏状态时,才使用全屏模态遮罩。原因:用阻断式遮罩覆盖一个 200 毫秒的写入操作,是最常见的自造“应用卡死”问题。
  9. 按层级选择呈现形式:行内 < snackbar < banner < 对话框。 选择可行的最低一级(见下表)。只有必须先作出决定才能继续操作时,才使用模态界面。原因:层级每升一级,用户就少一分控制权;而且只有对话框会阻止用户操作。
  10. 用户必须采取行动的信息绝不能只通过 snackbar 呈现。 snackbar 会自动消失,用户可能完全错过,屏幕阅读器也可能无法读到。对于十秒后仍然重要的信息,应使用 banner 或行内状态。
  11. 优先使用软删除 + 撤销,而不是确认对话框。 只有确实不可逆的操作才要求确认,并明确说明后果(“永久删除 12 条笔记”),而不是询问“确定吗?”,同时设置 barrierDismissible: false。原因:对 99% 确认无误的用户来说,撤销更快;对 1% 误操作的用户来说,也更安全。软删除写入路径参见 error-handling-typed-results。
  12. showDialog/showModalBottomSheet 返回 Future<T?>——null 表示已关闭,且应作为独立结果处理。 对密封结果使用 switch,并显式处理关闭情况;绝不要写 ?? true。原因:?? true 会悄无声息地把点击对话框外部转换为确认删除。
  13. 在 await 之前捕获 ScaffoldMessenger/Navigator,并在之后检查 mounted。 然后显示 snackbar。原因:写入操作返回时,BuildContext 可能已经不存在——这正是 use_build_context_synchronously 漏洞(async-safety)。
  14. 播报只有视觉上可见的内容。 出现的结果(已保存、已删除、已重试、已筛选)应通过 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 — 骨架屏、横幅和空状态组件所在的位置。

参考资料

评论

支持 Markdown