卷 IV · 色· CH 16· 角色表 + 色觉模拟 深度 16/28

颜色是角色,不是值

上一章你有了六七十个色值。这一章解决「哪个用在哪儿」——答案不是记住它们,是根本不去记:你写的应该是角色名,不是颜色。

on- 前缀的意思暗色不是取反别只靠颜色

on- 是什么意思

Material 3 的角色名有个一眼看不懂但极其好用的约定:

每个「面」都配一个「面上的东西」
primary            主色的面
onPrimary          放在 primary 上的字和图标

primaryContainer   主色的浅底容器
onPrimaryContainer 放在那个容器上的字

surface            页面的底
onSurface          放在页面底上的正文

on 读作「在……之上」。这个约定的价值是:它把「配对」这件事写进了名字里。

你永远不会问「这个按钮上的字用什么颜色」——按钮的底是 primary,字就是 onPrimary,没有第二个选择。而这一对的对比度是上一章那套色调差保证过的。

配对亮色对比度暗色对比度
onPrimary / primary6.44:17.70:1
onSurface / surface16.73:113.29:1

四个数全部远超 AA 的 4.5。你什么都没做,只是用对了角色。

暗色模式不是取反

很多人第一次做暗色模式的做法是:把所有颜色反相,或者手动挑一套「深色版」。两种都会翻车。

正确的模型是:角色不变,只是每个角色在两套主题里读的 tone 不同。

看那张表的两列 tone:

primary亮色 tone 40 → 暗色 tone 80
onPrimary亮色 tone 100 → 暗色 tone 20
primaryContainer亮色 tone 90 → 暗色 tone 30
onPrimaryContainer亮色 tone 10 → 暗色 tone 90
为什么暗色的主色要用 tone 80(更亮),而不是更暗

因为「主色」的职责是从背景里跳出来。亮底上要跳出来就得深,暗底上要跳出来就得浅。

这也解释了一个常见的翻车:直接把亮色模式的 #6750A4 放到暗色背景上,它会显得又暗又闷,因为它是为「从白底跳出来」设计的。

另一件事:暗色模式的背景不要用纯黑。纯黑 + 高饱和色会产生光晕(halation),字会发虚。Material 的暗色 surface 是 tone 10(#1C1B1E),不是 #000000

别在彩色背景上用灰字

这是《Refactoring UI》里我最想收进来的一条,因为它解释了一个很常见但说不出原因的「脏」。

场景:一块蓝色的卡片,主标题白色,你想加一行副标题——要比标题弱一点。你的手会自动伸向灰色。

然后那行字看起来就像蒙了一层灰。

要弱化,就从背景的同一个色相上降亮度

看上面 Demo 里那张对照表和那个色卡:

掺灰 #C7C7C7彩度 0.0000 对比 4.07:1
同色相 L=0.86彩度 0.0591 对比 4.48:1

掺灰把彩度按到了 0——那段字从此不属于这块背景,像是从别的地方贴上来的。

同色相的那个不但保住了彩度,对比度还更高(4.48 > 4.07)。它看起来是「这块蓝的一部分,只是淡一些」。

这条规则在 Material 3 里有现成的角色对应:onPrimaryContainer 之于 primaryContaineronSurfaceVariant 之于 surface——它们全都是同色相的,没有一个是纯灰。

每十二个男用户就有一个

8% 的男性有某种红绿色觉障碍。这不是小众照顾——是每十二个男用户里就有一个。

把上面 Demo 切到「绿色盲 deutan」,看那两个状态标签:

正常色觉下 绿 #2E7D32 / 红 #D32F2FΔE = 0.2921
绿色盲下ΔE = 0.0670(只剩 23%)

它们基本分不开了。「已发货」和「支付失败」在这些用户眼里是同一个颜色的两个标签。

解法不是换一对「更好的红绿」

别只靠颜色。三种手段,可以叠加:

  1. 加图标——对勾 / 叹号 / 叉。这是最有效的一条,而且对所有人都有帮助。
  2. 加文字——「已发货」三个字本身就说清楚了,颜色只是加强。
  3. 拉开明暗——如果一定要用颜色,让这两个颜色不只差色相,还差亮度

第三条能量化:把 #2E7D32 / #D32F2F 换成 #1B5E20 / #EF5350(一深一浅),绿色盲下的 ΔE 从 0.0670 回到 0.2456——因为明暗差是所有色觉类型都能看到的

一个两秒钟的自查

把界面截图转成灰度。转完之后:

  • 能点的还认得出来吗?
  • 成功和失败还分得开吗?
  • 图表里的几条线还能对上图例吗?

这个检查同时覆盖了色觉障碍、阳光下看屏幕、黑白打印三种场景。第 28 章会把它升级成一套工作方法——干脆先做灰度,最后才加颜色

写进 Compose

// ✗ 写颜色 —— 暗色模式要全部再写一遍,而且没人保证对比度
Text("确认收货", color = Color(0xFF6750A4))
Box(Modifier.background(Color(0xFFE9DDFF)))

// ✓ 写角色 —— 两套主题自动,对比度有保证
Text("确认收货", color = MaterialTheme.colorScheme.primary)
Box(Modifier.background(MaterialTheme.colorScheme.primaryContainer)) {
    Text("预计明天送达", color = MaterialTheme.colorScheme.onPrimaryContainer)
}

// ✓ 状态:颜色 + 图标 + 文字,三重编码
AssistChip(
    onClick = {},
    label = { Text("支付失败") },
    leadingIcon = {
        Icon(Icons.Default.ErrorOutline, contentDescription = null)   // ← 关键
    },
    colors = AssistChipDefaults.assistChipColors(
        labelColor = MaterialTheme.colorScheme.error,
        leadingIconContentColor = MaterialTheme.colorScheme.error
    )
)

一条能写进 lint 的硬规矩:UI 层禁止出现 Color(0x…) 字面量,只允许 MaterialTheme.colorScheme.*。例外只有一处——定义主题本身的那个文件。

卷 IV 收尾

色这一卷四章,收敛得比任何一卷都狠:

用什么尺子OKLCH / HCT,不用 HSL
够不够深算,不看。正文 4.5:1,非文本 3:1
用哪个颜色一个种子,其余全部算出来
写在代码里的是什么角色名,不是色值

从第 1 章那 25 个手挑的颜色,到现在一个种子 + 一张角色表。这是全书最大的一次收窄——而且它顺带把暗色模式和无障碍一起解决了。

本章关掉的自由度本章 4 条 · 累计 46 / 78
  • 「这个字用什么颜色」——底是什么角色,字就是对应的 on-。不再单独挑。
  • 「暗色模式怎么配」——同一张角色表读另一列 tone,不取反、不手挑。暗色背景不用纯黑。
  • 「彩色底上的次要文字用什么灰」——不用灰。从背景同色相上降亮度。
  • 「状态怎么区分」——颜色 + 图标 + 文字三重编码,且两个状态要有明暗差。灰度截图能分开才算过。