成为全栈·Flutter App 篇·主题与可访问性:深浅色、大字号和触控体验

成为全栈·Flutter App 篇·主题与可访问性:深浅色、大字号和触控体验

这一篇讲视觉,但重点不在"好看",在**"这些颜色和尺寸从哪来,以及谁来保证它们一直是对的"**。

做 App 的过程里有个反复出现的现象:设计稿上写的是 A,开发实现的是 B,评审时没人发现。

因为设计稿是一张图,代码是一堆字符串,两边没有任何机制关联。评审的人只能凭眼睛看,而深浅色下的细微差别、字号 1.5 倍后的布局断裂,眼睛是看不全的。

这篇讲 app/theme/ 那三百多行,以及一个我自己定的规矩:代码里不允许出现任何具体的色值。

从间距开始,因为间距最不容易出错

先看最简单的部分:

dart 复制代码
/// 4 基准间距刻度。移动端页面左右边距统一 [s4](16dp)。
abstract final class AppSpacing {
  static const double s1 = 4;
  static const double s2 = 8;
  static const double s3 = 12;
  static const double s4 = 16;
  static const double s5 = 20;
  static const double s6 = 24;
  static const double s8 = 32;
  static const double s10 = 40;

  /// 页面左右边距
  static const double page = s4;

  /// 触控目标下限(docs/flutter-app/02 §1 硬要求)
  static const double minTapTarget = 44;
}

只有 4、8、12、16、20、24、32、40 这八个值。

用刻度而不是任意值,是因为任意值会蔓延。今天用 16,明天看到某个地方"刚好差一点"就用 15,后天又用 17------最后整个 App 会有三十个不同的间距值,看起来都不协调但说不出哪里不对。

而那行注释 触控目标下限(docs/flutter-app/02 §1 硬要求) 说明了一件事:这个 44 不是设计偏好,是可访问性要求。

44dp 是苹果和安卓都给出的最小触控目标建议。低于这个值,手指点不准------尤其是手大的人、年纪大的人。

它被写在代码里而不是口头约定,意味着它可以被检查。(M4-25 后半段会讲怎么机器化验证。)

圆角和时长同理:

dart 复制代码
abstract final class AppRadius {
  static const double xs = 4; // 徽章、标签、小缩略图
  static const double sm = 6; // 输入框、次级按钮
  static const double md = 8; // 卡片、图片、按钮(默认)
  static const double lg = 12; // 底部面板、抽屉顶部
  static const double full = 999; // 头像、胶囊标签
}

abstract final class AppDuration {
  static const Duration fast = Duration(milliseconds: 120); // 按下态
  static const Duration base = Duration(milliseconds: 200); // 淡入、展开
  static const Duration page = Duration(milliseconds: 250); // 页面转场
  static const Curve curve = Curves.easeOutCubic;
}

每个圆角后面都写了"用在哪"。 这不是废话------半年后有人想用 md 做输入框,他就能看出"输入框应该是 sm",因为注释说了。

而 AppDuration 只有三档,因为它还有个额外作用:M4-14 讲过,无障碍要求在系统关闭动画时用 Duration.zero。 而 fast / base / page 这三个值和系统的"减弱动态效果"设置是可以对齐判断的。

为什么用 ThemeExtension 而不是 ColorScheme

Flutter 内置的 ColorScheme 有几十个字段,Theme.of(context).colorScheme.primary。用它不是不行,但它有个问题:

它把"语义色"和"具体颜色"混在了一起。 primary 是品牌主色还是"主要按钮的背景色"?如果是前者,那"标题文字的颜色"应该用什么?

所以现在的做法是自定义一个 ThemeExtension:

dart 复制代码
class AppColors extends ThemeExtension<AppColors> {
  static const AppColors light = AppPalettes.light;
  static const AppColors dark = AppPalettes.dark;
  ...
  @override
  AppColors lerp(ThemeExtension<AppColors>? other, double t) { ... }
}

Flutter 对 ThemeExtension 的要求是三件事:

一、必须可比较相等。 因为切换主题时 Flutter 要判断"颜色变了吗",变了就重建。

二、必须实现 copyWith 和 lerp。 lerp 用于主题动画------切换深浅色时颜色平滑过渡而不是瞬间跳变。少了它,切换会有生硬的闪烁。

三、可以挂在 ThemeData.extensions 里,通过 context.colors 取。 业务代码写 context.colors.textMuted,不写 Theme.of(context).colorScheme.onSurfaceVariant。

第三点带来的收益是:业务代码完全不知道颜色是从哪来的。 想改配色,只改 AppPalettes;想加一个语义色,只改那个类。

而这个写法强制了一个纪律:业务代码里不允许写颜色字面量。

为什么不允许写色值

这是这一篇的核心决定。

dart 复制代码
// ❌ 这样写是禁止的
Text('标题', style: TextStyle(color: Color(0xFF1A1A1A)))

// ✅ 只能这样写
Text('标题', style: context.text.title)

理由是深浅色。

设想有人写了 color: Color(0xFF1A1A1A)(接近黑)。在浅色主题下它是对的,但在深色主题下,接近黑的文字放在深色背景上,用户什么都看不见。

而这个 bug 在评审时极难发现------因为评审通常在浅色环境进行。只有切换到深色、而且刚好找到那一屏,才可能注意到。

用语义名之后,问题在类型层面就消失了:context.text.title 在深色下会解析成深色的文字色,因为它走的是主题。

这是一个"用架构消除错误类别"的例子。 不是"记得别写错",而是"写成这样就编译不过"。

同样的道理适用于字号:不用 fontSize: 14,用 context.text.bodySmall。因为字号还牵扯到大字号缩放(M4-13 讲过,textScaler 变化会让缓存失效)------如果字号散落在各处,有些地方会跟着缩放,有些不会,深浅色之外又多了一个不一致的维度。

渐变:ThemeExtension 的盲区

这里有一个必须承认的限制:

dart 复制代码
// 焦点区渐变(06 §2.1 `color.bg.wash`)。ThemeExtension 不能直接持有

ThemeExtension 不能直接持有渐变 (LinearGradient)。因为它要求可比较相等、可以实现 lerp,而渐变对象的相等比较语义不明确。

现在的解法是把渐变单独放一个扩展里:

dart 复制代码
class AppGradients extends ThemeExtension<AppGradients> {
  // wash / scrim / shimmer 等
}

一个 ThemeExtension 只能有一个类,所以需要多个扩展并存。这个限制有点烦,但它保证了颜色的相等语义清晰。

顺带说,如果渐变只用两个颜色、且那两个颜色都在 AppColors 里,那可以从颜色算出渐变------这是更干净的做法,但要多写一遍 lerp 逻辑。现在的选择是单独扩展,简单直接。

深浅色切换:不只是换颜色

切换主题时,除了颜色,还有几个东西要跟着变:

dart 复制代码
/// 焦点图使用原型图标和渐变;切换索引仅影响当前轮播组件。
class FocusStories extends StatefulWidget {

M4-10 讲过首页的焦点图。它用的是渐变背景------而渐变在深浅色下是不同的(浅色下要淡,深色下要亮)。

所以焦点卡片的构建逻辑要拿到当前主题:

dart 复制代码
gradient: LinearGradient(
  begin: Alignment.topLeft,
  end: Alignment.bottomRight,
  colors: [context.colors.wash, context.colors.surface],
)

所有依赖背景色的元素都必须走语义色。 而这正好是"禁止色值"这条纪律的另一个收益------它强制把所有这类地方都标出来。

M4-10 讲过那句注释:"焦点图使用原型图标和渐变;切换索引仅影响当前轮播组件。" 这句话在设计语境下没什么信息量,但在代码语境下它是在说"这个组件的外观依赖主题"。

大字号:最难处理的一个

前两个好办,深浅色切换是"换一个值",大字号是"布局可能要重排"。

M4-13 讲过 didChangeDependencies 里读 MediaQuery.textScalerOf(context) 来触发缓存失效。但那只是数据层------界面上更麻烦。

用户把系统字号调到最大,以下这些都会出问题:

元素 正常 大字号下
底部 Tab 图标 + 文字横排 挤不下,可能换行或者溢出
标签栏(chips) 一行几个 全部堆叠,甚至撑破容器
文章卡片 标题一行 标题占三行,摘要被挤掉
头像 + 昵称 横排 昵称换行,布局错乱

这些问题的共同点:它们不是"字体变大了",而是"可用宽度相对变小了"。

一个常见的错误解法是给这些地方设固定高度。那样会导致文字被裁掉或者溢出。 正确做法是让它们能自然换行。

比如标签栏用 Wrap 而不是 Row:

dart 复制代码
child: Wrap(
  spacing: 8,
  runSpacing: 8,
  children: [
    for (final t in tags.take(8))
      ActionChip(label: Text(t.name ?? ''), onPressed: () => search(t.name)),
  ],
)

Wrap 会在空间不够时自动换行 ,而 Row 不会。这是解决大字号问题最省事的办法------很多"大字号下布局崩了"的问题,换成 Wrap 就好了。

而底部 Tab 的图标尺寸,那属于设计稿和实现的分歧,需要人工对齐(M4-25 设计侧那 13 项待裁定里有这一项)。

触控目标:44 是硬线

最后是 M4-25 设计规范里那条硬要求:

dart 复制代码
/// 触控目标下限(docs/flutter-app/02 §1 硬要求)
static const double minTapTarget = 44;

问题是:怎么保证每个可点击的东西都达到了 44?

靠人眼看不出来。一个 32×32 的图标按钮,谁也不会去量。所以现在有两个机制:

一、视觉尺寸和点击区域分离。 一个看起来 24×24 的图标按钮,可以用 SizedBox 或 Padding 把点击区域撑到 44:

dart 复制代码
IconButton(
  constraints: BoxConstraints(minWidth: 44, minHeight: 44),
  icon: const PrototypeIcon('search', size: 20),
)

这样视觉上还是紧凑的,但手指点得准。 这是 Material Design 的标准做法。

二、机器化检查。 一个脚本扫所有可点击组件,验证它们的约束不小于 44。这个检查能抓到"忘了加"的情况,因为人的眼睛抓不到。

同样的思路也用在别处:

检查项 抓什么
触控目标 ≥ 44 忘了加 BoxConstraints
深浅色下的文字对比度 用了语义色但选错了
大字号下是否溢出 用 Row 而非 Wrap
是否存在色值字面量 没走语义色

这四类问题的共同点是"肉眼容易漏、机器容易抓"。 而凡是这类问题,就值得写一个检查脚本。

顺带说,深浅色对比度这个检查有个现实限制:代码高亮的颜色是第三方库(highlight.dart)生成的 ,它的深色主题配色不在我们的令牌体系里。所以代码块在深色下的对比度只能靠人看。

这是我在这个项目里最不愿意承认但必须承认的事------有一类问题只能靠人眼,而它恰好是设计上最重要的一类。

一条我一开始想省掉的事

那三个动效时长(fast / base / page)我最初是直接写数字的:

dart 复制代码
duration: const Duration(milliseconds: 200),   // 散落各处

改成令牌之后,立刻碰到一个问题:怎么在系统关闭动画时真的变成 0?

因为如果只是"定义一个常量",那代码里写死的 200 还在。需要的是每个用到动效的地方都判断一次系统设置,而那是个容易漏的判断。

现在的做法是在少数几个真正需要动效的地方用 M4-14 那个模式:

dart 复制代码
duration: MediaQuery.disableAnimationsOf(context)
    ? Duration.zero
    : const Duration(milliseconds: 250),

注意我没有做一个"全局开关"来省事。 因为"关闭动画"不是全局统一的------目录滚动要关、页面转场可以保留、横滑交互条可能还是要动。一刀切的开关会连用户不需要关的部分也关掉。

代价是每个动效点都要写这一行,而这是可检查的 ------一个脚本可以扫"用了 Duration(milliseconds: X) 但前面没有 disableAnimationsOf 判断"的地方。

所以真正省事的做法不是抽象,是加检查。 抽象会让不同场景被迫一致,而一致性在这里不是优点。

小结

这一篇的结论不多,但每一条都在解决一类"肉眼难发现"的问题:

  1. 只有八个间距值 ------ 任意值会蔓延,且无法察觉。
  2. 语义名代替色值和字号 ------ 用架构消除错误类别,而不是靠人记得。
  3. 业务代码不知道颜色从哪来 ------ 想改配色只改一处。
  4. 渐变是 ThemeExtension 的盲区 ------ 需要单独扩展,这是个已知限制。
  5. 大字号 = 宽度相对变小 ------ 用 Wrap 让它自然换行,而不是固定高度。
  6. 44dp 触控目标可以机器检查 ------ 肉眼量不出来。
  7. 不做的抽象用检查来补 ------ "关闭动画"的判断逐点写,但可以扫。

第 2 条是这一篇的核心,而且它和 M4-24 讲的图片鉴权头是同一个原则:用结构消除错误,比用纪律减少错误可靠。 因为纪律依赖于每个开发者在每个时刻的注意力,而结构是自动生效的。

第 7 条则是一个反思:我一开始想做一个"全局动效开关",但那会牺牲场景差异。后来发现正确的解法不是抽象更细,而是加检查 ------这在设计系统里是个通用思路,因为它把"保证一致性"的成本从"每次写代码时记得"降到了"CI 里跑一遍"。

下一篇讲测试分层------它是这个系列里唯一一个不产出用户可见功能的话题,但它是唯一一个能防止回归的机制。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
传奇开心果编程18 小时前
【Compose Multiplatform跨端声明式UI实用技术】第10课:CMP 导航——Navigation 3 实战
移动开发·编程语言理论·开发范式·编程框架·声明式ui·cmp导航·navigation 3
用户954215733348519 小时前
只是选个照片,怎么留下了这么多访问记录?Flutter 图片选择器排查与优化实录
flutter
FungLeo1 天前
成为全栈·Flutter App 篇·第四批复盘:复用了什么,移动端又新增了什么
flutter·移动开发·跨端开发·成为全栈·api契约·全栈复盘
jason.zeng@15022071 天前
使用vscode+flutter开发android程序(入门)
android·vscode·flutter
jason.zeng@15022071 天前
Flutter 项目标准分层目录(repository/dto/page,对齐后端思想)
flutter
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第7课:列表与惰性布局
移动开发·列表·编程语言理论·开发范式·编程框架·声明式ui·惰性布局
传奇开心果编程3 天前
【Compose Multiplatform跨端声明式UI实用技术】第6课:常用组件与 Material 3 设计系统
移动开发·编程语言理论·开发范式·编程框架·声明式ui·常用组件·material 3 设计系统
程序员老刘3 天前
跨平台开发地图 | 2026年10月
flutter·ai编程·客户端
林瞅瞅3 天前
Flutter 开发环境从零搭建与调试记录
flutter