关于 accessibility-as-code
面向 Flutter 开发者的无障碍界面编写规范,涵盖语义、文字缩放、对比度、点击目标和遍历顺序,并建议通过组件测试验证。
主要功能
- 01为交互控件添加语义信息
- 02为图像添加标签或排除装饰图像
- 03遵循系统文字缩放和粗体设置
- 04使用非颜色信号表达状态
- 05设定对比度与点击目标要求
- 06定义辅助技术的遍历顺序
适用场景
- 01构建适配屏幕阅读器的控件
- 02支持放大文字且避免截断
- 03让状态指示不依赖颜色也易于理解
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill accessibility-as-code安装后在 Claude Code 中输入/accessibility-as-code确认已加载。
详细介绍
无障碍是编码规范,而非检查清单
编写组件时,就要将无障碍纳入其中——它和「按钮确实能用」处于同一优先级。没有针对这些内容的 lint:flutter_lints 和 very_good_analysis 都不包含任何 a11y 规则。落实这些要求靠组件本身、审查自己的 diff 和组件测试。适用于每个交互式 View、每个 Icon/Image、每种状态和每个颜色选择。
测试方法(指南匹配器、黄金测试通道、RTL、如实说明符合性限制)位于 widget-golden-and-a11y-testing;本技能讲的是编写时的规范。
不可妥协的规则
- 每个交互节点都要添加
Semantics(button: true, label: ...)——没有语义信息的原始GestureDetector会悄无声息地将所有屏幕阅读器和开关控制用户拒之门外。正确的语义信息能让 TalkBack、VoiceOver、Switch Access 和 Switch Control 开箱即用;这是完成定义的一部分,绝不是可以留到以后处理的待办事项。 - 每个
Icon/Image都要设置semanticLabel,或用ExcludeSemantics包裹,因为它是装饰元素。没有第三种选择——未加标签的Icon对屏幕阅读器来说不可见。 - 在构建时从
MediaQuery读取 a11y 状态,绝不要从应用状态读取。MediaQuery是一个按设计即可正确触发失效的InheritedWidget;通过 provider 传递它,会把编译器保证的重建换成一帧的陈旧状态,而在这个领域,出错就意味着彻底失败。 - 绝不使用
MediaQuery.withClampedTextScaling或textScaleFactor。 这两者都是限制缩放的变通做法,会悄悄破坏文本缩放矩阵测试,而对比度和点击目标指南仍然会显示绿色通过。使用源码 grep 检查是否存在这些用法(参见反模式)。 - 绝不使用
FittedBox、计算得出的fontSize或TextOverflow.ellipsis来让标签适应空间。 它们会把「在 200% 缩放时放不下」从明显的测试失败变成设备上的截断或缩小文本。应构建高度灵活的布局,让文本自动换行。 - 绝不只通过颜色表达状态。 每种状态信号都至少要搭配一种非颜色通道——图标 + 文本标签 + 形状/图案 + 位置。仅靠颜色表达的状态,在
invertColors、灰度色彩校正下,以及对所有屏幕阅读器用户来说,都是不可见的。 - 在两种主题下,都要根据合成后的背景测量对比度——正文/图标文本 ≥ 4.5:1(AA 1.4.3),大号/主视觉文本 ≥ 3:1(AA);大号文本不应比正文文本面临更高的门槛。当填充、渐变或半透明效果介于文本与表面之间时,绝不要用名义上的令牌颜色进行测试。
- 每个交互目标都要达到 ≥ 44×44 逻辑像素,并且支持单击。 不得只提供长按或需要精准手势的交互方式;交接操作和行动不便用户都需要大而宽容的点击目标。
- 使用
sortKey明确编写遍历顺序,绝不要依赖布局继承的顺序——为拇指优化的视觉位置,会严重恶化屏幕阅读器/开关控制用户的线性扫描体验。 - 遵循
boldText设置;减少动画时仍保留触觉反馈。 硬编码fontWeight会直接忽略该设置。在这里从MediaQuery读取disableAnimations,但应通过design-system-structure的减少动画令牌和resolveMotion辅助方法来决定如何简化动画(该技能负责实现机制)——如果动画在没有减少动画方案时仍传递含义,那就是一个缺陷。
a11y 状态的来源
从 context 读取平台无障碍状态,从 notifier 读取应用状态:
@override
Widget build(BuildContext context) {
final boldText = MediaQuery.boldTextOf(context);
final highContrast = MediaQuery.highContrastOf(context);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
// textScaler is deliberately NOT read and NOT clamped. Text scales itself.
...
}
MediaQuery.highContrastOf 仅适用于 iOS,在 Android 上始终为 false(AccessibilityFeatures.highContrast 的文档说明为「仅支持 iOS」)。可以酌情读取此标志,但绝不要据此启用或禁用任何功能;应提供应用内对比度/主题控制,作为适用于所有平台的机制。invertColors 是系统合成滤镜——绝不要重新实现它;只要不单独依靠颜色表达含义,屏幕反色就不会改变 UI 所表达的内容。
点击目标:错误做法与正确做法
// WRONG — invisible to a screen reader, a clamping hack, a hidden overflow bug
class ItemTile extends StatelessWidget {
const ItemTile({super.key, required this.item, required this.onTap});
final Item item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return MediaQuery.withClampedTextScaling( // (1) BANNED clamp
maxScaleFactor: 1.3,
child: GestureDetector(
onTap: onTap,
child: Column(
children: [
const Icon(Icons.star), // (2) no semanticLabel
Text(
item.title, // (3) no Semantics button role
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.normal, // (4) ignores boldText
),
overflow: TextOverflow.ellipsis, // (5) HIDES the overflow bug
),
],
),
),
);
}
}
// RIGHT — semantics authored, text free to scale, target ≥ 44px, non-color state
class ItemTile extends StatelessWidget {
const ItemTile({super.key, required this.item, required this.onToggle});
final Item item;
final void Function(ItemId id) onToggle; // resolve at tap from the stable id
@override
Widget build(BuildContext context) {
final bold = MediaQuery.boldTextOf(context);
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context); // non-null getter (i18n-rtl-l10n owns this contract)
return Semantics(
container: true,
button: true,
label: item.title, // display label, not a sentence
// Non-color state: read out AND flip an icon (below), never color alone.
value: item.isSelected ? l10n.selected : l10n.notSelected, // localized, not literal
sortKey: OrdinalSortKey(item.priority.toDouble()), // priority, not layout
child: GestureDetector(
behavior: HitTestBehavior.opaque, // whole cell is the target
onTap: () => onToggle(item.id), // stable id, no stale-closure capture
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
child: ExcludeSemantics( // face already announced above
child: Row(
children: [
Icon(item.isSelected ? Icons.check_circle : Icons.circle_outlined),
Text(
item.title, // no ellipsis, no FittedBox
style: theme.textTheme.bodyLarge!.copyWith(
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
),
),
);
}
}
label 是显示标签,绝不是更长的语音播报内容——类型系统无法区分这两个 String,因此除非将它们分开,否则使用屏幕阅读器的用户每次操作都会听到整句话。应在点击时根据不可变 ID 解析操作;如果在闭包中捕获可变字段,快速再次点击时就会触发过期值。
文本缩放:直觉恰恰是问题所在
当固定布局在 200% 缩放下溢出时,人们的本能反应是禁用文本缩放以保持稳定。这种本能就是缺陷。自动缩小——FittedBox、计算得出的 fontSize、maxLines + ellipsis——只是同一缺陷的伪装:布局看起来依然整洁,但用户的设置悄无声息地失效,而所有准则检查仍然全绿。
- 遵从
MediaQuery.textScalerOf(context),也就是不要动它。Flutter 文本组件已经会进行缩放。 textScaleFactor已弃用,改用TextScaler,正是为了支持 Android 的非线性缩放;使用它几乎总是意味着设置了缩放上限。- 构建高度由内容决定或可灵活调整的布局;让文本按需换成任意多行。标题和展示字号可以限制自身的增长,但应换到第二行,而不是截断。
- 在无障碍文本字号下,让水平行(标签栏、工具栏)重新排布,而不是裁切内容。
- 在数字会变化或需要对齐的地方使用
FontFeature.tabularFigures()(或.monospacedDigit()风格的等宽数字),避免布局重排或抖动。 - 让溢出在测试中明显暴露。Widget 测试中的红黄溢出条纹就是反馈机制;而设备上被截断的单词则是无人报告的失败。
绝不只靠颜色表达状态
无需依靠颜色,就应能识别真实状态——已选中、活动中、待处理、已完成、错误、已禁用。应通过相互独立的渠道重复编码:
- 图标 / 字形,其形状随状态变化(灰度下仍清晰可辨)。
- 文本标签——表示状态的词语,通过本地化处理。
- 形状 / 图案 / 轮廓——单向条纹、基准线或缺口。
- 位置——在标题下方,将需要关注的项目排在最上方。
通过 Semantics 向辅助技术公开状态——使用 value/hint,或针对临时播报使用 liveRegion——绝不能只依靠亮度变化:
Semantics(
liveRegion: true, // announced when it changes, e.g. "Sync complete"
label: statusLabel(status), // the word, not the color
child: StatusBadge(status: status), // icon + label + shape, color is decoration
)
将视觉反馈与 HapticFeedback.selectionClick() 结合,让确认反馈在减少动态效果或低视力情况下依然有效。对于任何锁定的“活动中”视觉状态,都应设置超时以强制清除——卡住的状态既会造成视觉误导,也会留下过期的语义值。
与合成背景的对比度
在浅色和深色模式下,根据像素实际合成出的背景验证对比度,而不是根据标称令牌:
- 正文和有意义的图标 / 轮廓 ≥ 4.5:1(AA 1.4.3);正文在可行时应达到 7:1(AAA 1.4.6)。大号 / 标题文本 ≥ 3:1(AA)或 4.5:1(AAA)——对比度要求从不高于正文。
- 绝不要在渐变、半透明玻璃效果或标签填充上呈现关键数字或标签;应将其放在不透明表面上。
- 任何承载文本的强调色填充,都必须预先搭配在该填充上对比度达到 4.5:1 的前景色,并在深色模式下重新验证(深色值需手动调整,绝不能自动翻转)。
- 避免红绿混淆:中性值使用中性色文字;“成功”应为绿色 + ✓ 字形 + 文字;危险红色只用于破坏性操作。
- 在灰度模式下,以及在红绿色盲 / 红色盲 / 蓝黄色盲模拟下验证整个界面。
遍历顺序是一项设计决策
针对拇指触达范围优化的布局(将最高优先级控件放在靠下且居中的位置),再加上 Flutter 默认的行优先遍历,会让最重要的控件成为线性扫描时最后到达的对象。使用一个参数,让遍历顺序与位置脱钩:
Semantics(
sortKey: OrdinalSortKey(item.priority.toDouble()), // authored from priority
... )
在测试中断言 sortKey 顺序与优先级顺序一致(而非布局顺序)。了解其局限:Flutter 没有发布任何 Switch Access 支持声明,也没有可模拟扫描或组选择的 API,因此这只是对设计意图的回归保护——只有在真实设备上通过 TalkBack、VoiceOver 和 Switch Access 测试,才能作为符合性证据。触控、开关和屏幕阅读器是三个不同的渠道:要么兼顾三者进行设计,要么明确说明舍弃了哪一个。
反模式
MediaQuery.withClampedTextScaling/textScaleFactor——未来的贡献者看到溢出测试失败时,可能会想到这个一行“修复”;它会悄无声息地破坏整个文本缩放测试矩阵。在 CI 中通过grep -rn "withClampedTextScaling\|textScaleFactor" lib/禁止使用。- 在实际标签上使用
FittedBox/ 计算得出的fontSize/ellipsis——同一个错误换了伪装;设置不再生效,而布局却依然整齐。 - 未标注的
Icon/Image——屏幕阅读器无法识别;必须添加标签或使用ExcludeSemantics。 - 从 provider 读取
boldText/highContrast/disableAnimations——会滞后一帧,而这些设置出错会导致彻底失效。 - 基于
highContrastOf设置门控条件——在 Android 上始终为 false;这会悄无声息地在占多数的平台上禁用你的功能。 - 仅用颜色表达状态——在
invertColors、灰度模式和屏幕阅读器下都会失效。 - 在
onTap闭包中捕获可变字段——快速重复点击时会触发旧值;应在点击时根据稳定 ID 解析值。 - 仅支持精确手势或长按的操作方式——会排除行动不便的用户和需要交接使用的场景;应提供 ≥44px 的单击操作。
- 在测试中使用
containsSemantics(...)——已弃用;请改用isSemantics(...)。
完成标准
对于任何带有点击目标或状态的 widget:
- 提供包含
button: true、仅供显示的label和手动指定的sortKey的Semantics节点。 - 为每个
Icon/Image添加标签,或明确使用ExcludeSemantics。 - 不截断、不使用
FittedBox,也不在实际标签上使用ellipsis;在TextScaler.linear(2.0)下渲染时不溢出。 - 遵循
boldText设置;面向用户的文本不使用硬编码的fontWeight。 - 每种状态都带有非颜色提示;瞬时状态使用
liveRegion或value;反馈包含触觉提示。 - 在两种主题下,基于合成后的背景验证对比度(正文 ≥4.5:1 / 大号文本 ≥3:1),并通过灰度 + CVD 检查。
- 交互目标至少为 44×44,且支持单击操作。
- 添加测试,使用
isSemantics(...)断言语义,并验证遍历顺序与优先级顺序一致。
相关技能
widget-golden-and-a11y-testing— 用于验证此处编写内容的 guideline 匹配器、golden 测试流程、RTL golden 图,以及对无障碍符合性局限的如实说明。i18n-rtl-l10n— 通过 gen-l10n/ARB 路由状态标签和播报,并使用 Directional 几何属性,从设计上确保 RTL 正确。design-system-structure— token→theme 分层、手动编写的ColorScheme、确保对比度配对可靠的禁止原始值检查,以及由该技能负责的减弱动态效果 token +resolveMotion辅助函数(此技能读取标志,该技能解析动态效果)。state-management-riverpod— 两项技能共同负责的分工:应用/领域状态通过 Riverpod notifier 流转,而平台/无障碍标志从MediaQuery/BuildContext读取,绝不经由 provider 路由。widget-composition— 作为这项语义工作基础的小型 const View、无状态 widget,以及 dispose 规范。design-review-workflow— 构建结束时的截图检查(浅色/深色 × RTL × 最大字号 × 减弱动态效果),其中无障碍底线始终是阻断条件。
References
- Flutter accessibility: https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility
Semanticswidget: https://api.flutter.dev/flutter/widgets/Semantics-class.htmlMediaQueryDataaccessibility flags: https://api.flutter.dev/flutter/widgets/MediaQueryData-class.htmlTextScaler: https://api.flutter.dev/flutter/painting/TextScaler-class.htmlOrdinalSortKey: https://api.flutter.dev/flutter/semantics/OrdinalSortKey-class.html- WCAG 2.2 contrast (1.4.3 / 1.4.6): https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html