一个种子色长出一整套
第 1 章数出来 25 个颜色。这一章把它收成 1 个——不是「少用颜色」,是只挑一个,剩下的全部算出来。这也是全书技术含量最高的一台引擎。
你需要的颜色比你以为的多
先纠正一个方向。你可能以为「收窄颜色」意味着少用颜色——正好相反。
《Refactoring UI》里有一节的标题就是「You need more colors than you think」。一个真实的界面需要的不是「一个蓝色」,而是:
- 主按钮的填充色(要够深,白字能站住)
- 次要按钮的边框色(浅一点)
- 选中态的浅底色(很浅,深色字要能站住)
- 那个浅底上的文字色(很深)
- 暗色模式下以上全部的对应版本
光一个「蓝」就要五到十档。三个主色 + 中性色,你需要的是六七十个色值。
是「这六七十个色值,是你一个个挑的,还是算出来的」。
一个个挑 = 六七十个随手定的数(第 1 章数出来的 25 个只是冰山一角,因为大部分人根本没做暗色模式)。
算出来 = 一个种子色,加一套规则。
Material 3 的做法:HCT
Material 3 用的取色空间叫 HCT,三个分量:
第三个是关键。T 直接就是 L*,而 L* 和第 14 章那个对比度公式是同源的。于是:
「tone 40 的字配 tone 100 的底」——这一对的对比度是有下限保证的,不管你的种子色是什么颜色。
这就是 Material 3 那套角色(primary 用 tone 40、onPrimary 用 tone 100)为什么天生合规:它们不是挑出来正好过 4.5:1 的,是按色调差排出来的,必然过。
一条色阶的生成规则很朴素:色相锁死、彩度按角色定档、只让色调在动。
换几个种子色试试。注意五条色阶的彩度是固定的:
看最后两行——Material 的中性色也不是纯灰,它带着种子色的色相,只是彩度压到了 4 和 8。这和这本书自己那条 C = 0.004 的中性阶是同一个思路。
这台引擎是真的,而且对过答案
HCT 不好实现——它需要完整的 CAM16 色貌模型(含视觉环境参数)、L* 与 Y 的往返、以及一个色域边界求解器(当你要的彩度在那个亮度上塞不下时,得沿 sRGB 立方体的表面找最远点)。
所以这台引擎必须验。验的方式是拿 Google 官方的 material-color-utilities 当裁判:
而且引擎里那两个关键矩阵不是抄 Google 源码里的硬编码常数,是从 M16 × sRGB→XYZ 现算的——算出来和它对到小数点后 15 位(0.001200833568784504)。
Material 官方文档里印的那张「基线调色板」(种子 #6750A4)和 material-color-utilities 现在算出来的不完全一样,好几格差 1/255。
比如 primary tone 10:文档写 #21005D,库算出来是 #22005D。
差异来自文档那张表比库旧。以库为准——因为你的 App 运行时用的是库。
种子色从哪来
那唯一要挑的那个颜色呢?三种来路,按可靠性排:
- 品牌已经有了——直接用,不要「调整一下更适合 UI」。品牌色就是种子,剩下的 HCT 会替你摆平。
- 从用户壁纸提取——这就是 Material You。
material-color-utilities里的QuantizerCelebi+Score做的就是这件事:量化出主色,再按「够不够艳、够不够特别」打分挑一个。 - 自己定——那就挑一个你喜欢的、彩度足够高的颜色。彩度低的种子会长出一整套发灰的色阶,因为规则里的「至少 48」救不了一个本身就没有彩度的色相。
// Android 12+ 直接用系统的动态取色 —— 种子来自用户壁纸
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
// 自己的品牌色:用 Material Theme Builder 生成一次,
// 把结果写死进代码。不要在运行时算 —— 没必要,而且 HCT 求解不便宜。
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4), // primary40 ← 种子
onPrimary = Color(0xFFFFFFFF), // primary100
primaryContainer = Color(0xFFE9DDFF), // primary90
onPrimaryContainer = Color(0xFF22005D), // primary10
// …剩下的 20 多个角色同理,全部来自那一个种子
)
MaterialTheme(colorScheme = colorScheme) { App() }
注意每行后面的注释:那才是这些值的出处。没有注释的话,三个月后有人想改主色,会以为要改 25 个地方——实际上只要改一个种子重新生成。
语义色怎么办
成功绿、警告黄、危险红——这些不能从种子长出来,因为它们的色相是约定俗成的,不能跟着品牌走。
做法是:它们各自当一个独立的种子,跑同一套规则。
error 种子 #B3261E → error40 / onError100 / errorContainer90 / onErrorContainer10 success 种子 #386A20 → 同上四档 warning 种子 #7D5700 → 同上四档
这样它们和主色虽然色相不同,但明暗结构完全一致——放在一起不会有一个特别跳。Material 3 官方只给了 error 一族,success 和 warning 需要你自己按同样的规则扩,这是很常见的一步。
- 「这里用哪个颜色」——一个种子色 + 一套规则,六七十个色值全部算出来。你只挑种子。
- 「浅一点的那个蓝用什么」——同一条色阶上换一个 tone,不是重新调一个颜色。
- 「语义色怎么和品牌色协调」——各自当种子跑同一套规则,色相不同但明暗结构一致。