颜色是角色,不是值
上一章你有了六七十个色值。这一章解决「哪个用在哪儿」——答案不是记住它们,是根本不去记:你写的应该是角色名,不是颜色。
on- 是什么意思
Material 3 的角色名有个一眼看不懂但极其好用的约定:
primary 主色的面 onPrimary 放在 primary 上的字和图标 primaryContainer 主色的浅底容器 onPrimaryContainer 放在那个容器上的字 surface 页面的底 onSurface 放在页面底上的正文
on 读作「在……之上」。这个约定的价值是:它把「配对」这件事写进了名字里。
你永远不会问「这个按钮上的字用什么颜色」——按钮的底是 primary,字就是 onPrimary,没有第二个选择。而这一对的对比度是上一章那套色调差保证过的。
| 配对 | 亮色对比度 | 暗色对比度 |
|---|---|---|
onPrimary / primary | 6.44:1 | 7.70:1 |
onSurface / surface | 16.73:1 | 13.29:1 |
四个数全部远超 AA 的 4.5。你什么都没做,只是用对了角色。
暗色模式不是取反
很多人第一次做暗色模式的做法是:把所有颜色反相,或者手动挑一套「深色版」。两种都会翻车。
正确的模型是:角色不变,只是每个角色在两套主题里读的 tone 不同。
看那张表的两列 tone:
因为「主色」的职责是从背景里跳出来。亮底上要跳出来就得深,暗底上要跳出来就得浅。
这也解释了一个常见的翻车:直接把亮色模式的 #6750A4 放到暗色背景上,它会显得又暗又闷,因为它是为「从白底跳出来」设计的。
另一件事:暗色模式的背景不要用纯黑。纯黑 + 高饱和色会产生光晕(halation),字会发虚。Material 的暗色 surface 是 tone 10(#1C1B1E),不是 #000000。
别在彩色背景上用灰字
这是《Refactoring UI》里我最想收进来的一条,因为它解释了一个很常见但说不出原因的「脏」。
场景:一块蓝色的卡片,主标题白色,你想加一行副标题——要比标题弱一点。你的手会自动伸向灰色。
然后那行字看起来就像蒙了一层灰。
看上面 Demo 里那张对照表和那个色卡:
掺灰把彩度按到了 0——那段字从此不属于这块背景,像是从别的地方贴上来的。
同色相的那个不但保住了彩度,对比度还更高(4.48 > 4.07)。它看起来是「这块蓝的一部分,只是淡一些」。
这条规则在 Material 3 里有现成的角色对应:onPrimaryContainer 之于 primaryContainer、onSurfaceVariant 之于 surface——它们全都是同色相的,没有一个是纯灰。
每十二个男用户就有一个
约 8% 的男性有某种红绿色觉障碍。这不是小众照顾——是每十二个男用户里就有一个。
把上面 Demo 切到「绿色盲 deutan」,看那两个状态标签:
它们基本分不开了。「已发货」和「支付失败」在这些用户眼里是同一个颜色的两个标签。
是别只靠颜色。三种手段,可以叠加:
- 加图标——对勾 / 叹号 / 叉。这是最有效的一条,而且对所有人都有帮助。
- 加文字——「已发货」三个字本身就说清楚了,颜色只是加强。
- 拉开明暗——如果一定要用颜色,让这两个颜色不只差色相,还差亮度。
第三条能量化:把 #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 收尾
色这一卷四章,收敛得比任何一卷都狠:
从第 1 章那 25 个手挑的颜色,到现在一个种子 + 一张角色表。这是全书最大的一次收窄——而且它顺带把暗色模式和无障碍一起解决了。
- 「这个字用什么颜色」——底是什么角色,字就是对应的
on-。不再单独挑。 - 「暗色模式怎么配」——同一张角色表读另一列 tone,不取反、不手挑。暗色背景不用纯黑。
- 「彩色底上的次要文字用什么灰」——不用灰。从背景同色相上降亮度。
- 「状态怎么区分」——颜色 + 图标 + 文字三重编码,且两个状态要有明暗差。灰度截图能分开才算过。