Published on

自适应材质的对比度账:iOS 26 Liquid Glass 为什么管不住前景颜色

Authors
  • avatar
    Name
    Jack Qin
    Twitter

iOS 26 的 Liquid Glass 是一种对背景自适应的材质。浮在浅色内容上它压暗自己、浮在深色内容上它提亮自己,始终维持玻璃后面那点若隐若现的层次感。在系统自己的浅色/深色主题下,这套自适应几乎不需要你操心——因为系统知道背景是什么。

但"自适应"是有前提的:它假设背景是材质能感知、且语义可预测的。一旦你把玻璃 chrome 浮到一张你不控制的内容上——比如一张暗色卫星地图、一层热力图叠加、一片红土影像——这个前提就破了。材质还在尽职地"自适应",可它适应的方向不再服务于可读性。这时候,"自适应"从特性变成了对比度的敌人。

本文不讲某个组件怎么写,而是想把自适应材质下的对比度这件事拆成一笔清晰的账:它为什么是两层账、这两层各受什么控制、为什么只调一层一定会翻车。理解了这笔账,你面对的就不只是 Liquid Glass,而是一整类"自动反色/毛玻璃/混合模式叠在不可控背景上"的问题。


先厘清一个常见误解:colorScheme 管的不是材质

浮在暗色地图上的玻璃 chrome 渲染成了透明黑——内容读不清。绝大多数人的第一反应是给它 colorScheme="light",期待玻璃变浅。

它不会。要理解为什么,得分清两个正交的东西:

  • trait collection 的 overrideUserInterfaceStyle:这是 colorScheme="light" 真正控制的东西。它告诉视图树"按浅色主题来解释那些语义颜色"(比如 labelsystemBackground 这类会随主题翻转的动态色)。它影响的是颜色的语义解析
  • UIGlassEffect 的背景自适应:这是材质层自己的行为,它读取的是玻璃背后实际像素的明暗,跟 trait collection 的主题设定不挂钩

换句话说,colorScheme="light" 改的是"语义色怎么翻译",而玻璃变透明黑是"材质对背景像素作色"的结果——两件事走的是两条管线。你把主题设成 light,材质照样会去感知它背后那张暗色地图,照样压暗。这就是为什么单靠 colorScheme 按不住它:你在 A 管线上拧旋钮,问题出在 B 管线上。

要真正锁住材质层,得给它一个显式的 tint——一层叠在材质之上的有色"洗白"。在原生层这是 UIGlassEffect.tintColor;在 RN 封装里它可能叫 tintColor(直接用 GlassView 时)或 tint(用上层 LiquidPanel 这类 wrapper 时)。名字不重要,重要的是它和 colorScheme 不是替代关系,而是互补的另一半

// 不够 —— 只拧了语义色管线,材质仍会自适应暗色地图 → 透明黑
<LiquidPanel colorScheme="light" glassEffectStyle={glassVariant}>

// 完整 —— colorScheme 管语义色,显式白 tint 锁住材质层
<LiquidPanel
  colorScheme="light"
  tint={glassTint}           // rgba(255,255,255,<opacity>),把材质钉成浅色
  glassEffectStyle={glassVariant}
>

对比度是两层账,不是一层

锁住材质只解决了一半。这正是这类问题最反直觉的地方:对比度从来不是"前景 vs 背景",而是"前景 vs 材质"再叠上"材质 vs 背景"。 当材质本身会自适应时,账就分成了两层,你必须两层都钉死,否则它们会互相打架。

把材质用白 tint 钉成浅色之后,第二层账登场:前景该是什么颜色? 既然玻璃永远是浅色材质,前景文字/图标就必须是暗色——而且关键是,前景不能再跟着背景自适应

这正是新手最容易埋的雷。很多代码里前景颜色是这么写的:

// 错 —— 前景跟着地图样式自适应,和"被钉成浅色的材质"打架
const isLightMap = layers.mapStyle === 'light'
const fabIconColor = isLightMap ? colors.ink : colors.white

逻辑看着合理:"地图暗就用白字,地图亮就用暗字"。但它忘了中间还隔着一层被你强制锁成浅色的玻璃。地图是暗的,于是这段逻辑给出白字——白字配浅色玻璃,糊成一团。前景的自适应和材质的强制,方向相反,直接互殴。

正确的做法是让前景钉死到材质,而不是钉死到背景

// 对 —— 前景跟着"玻璃变体"走,因为玻璃才是它的直接背景
const isClear = glassVariant === 'clear'
const fabIconColor = isClear ? colors.ink : colors.navy

一旦你接受"对比度是前景 vs 材质",这段逻辑就自然了:材质被锁成浅色,前景就钉成暗色,两层都不再参考那张不可控的地图。双重自适应被替换成双重确定性,对比度才稳。


描边:当你不想锁死材质时的备选层

上面的方案有个隐含选择:把材质锁成不透明度足够高的浅色(比如 0.6 的白),这样暗色前景不靠任何额外手段就有足够对比度。这是最干净的路子——没有描边、没有 halo,前景就是单纯一层暗色文字

但值得记下另一条备选机制,因为它揭示了对比度账还有第三种付法。如果某个变体你就是想保留材质的透明感(tint 不透明度很低,甚至全透明),那暗色前景在变化的背景上又会不稳——这时可以给前景加描边/halo:暗色文字配一圈浅色描边,或浅色文字配暗色 halo,让前景自带对比度,从而不依赖材质。

实践里这套描边能力往往是"保留但默认关闭"的状态——机制留在组件里,用一个 flag 控制:

// 描边禁用(当前锁定的 clear 变体靠 0.6 白 tint 就够对比度了)。
// 要恢复:把 flag 翻回变体判断,并给一个 halo 颜色。
const fabStroke = false // 曾是 glassVariant === "clear"
const fabIconHalo = undefined // 曾是 "rgba(0,0,0,0.75)"

这给了一个有用的设计视角:对比度可以在三层里任意一层付账——锁材质、锁前景、给前景自带描边。选哪层,取决于你愿意放弃多少材质的视觉特性。想要纯净的玻璃质感,就在材质和前景两层付清;想保留透明感,就把账挪到描边那层。


一条只活在文档里的契约会烂掉

这套"colorScheme + 显式 tint 缺一不可、前景钉死到变体"的规则,最初只写在一份 playbook 里。结果是:tint 这一半在大多数玻璃调用点上根本没被真正应用——只有少数几个表面记得加。规则在文档里完好无损,在代码里名存实亡。

这暴露了自适应材质契约一个脆弱的本质:它是一条必须在每个调用点都被重复满足的约束,而不是一次性的全局设置。少一个调用点没加 tint,那个表面就在暗色背景上悄悄糊掉,而文档依旧"正确"。一条不能落到每个调用点、并被某种机制检查的契约,会随着新表面的增加而稳定地腐烂。

落地上这意味着:要么把"白 tint + 暗前景"封装进一个默认就正确的组件(让调用点想绕过都难),要么用测试/lint 守住"每个玻璃表面都接受并应用了 tint"。把契约从"文档约定"降级成"调用点纪律",是它烂掉的开始。


可迁移的那一层

Liquid Glass 只是引子。真正可迁移的认知是关于自适应渲染这一整类东西的:

任何"对背景自适应"的渲染——自动暗黑反色、毛玻璃材质、mix-blend-mode、自适应图标着色——在被放到你不控制的背景上时,"自适应"都必须被显式约束回"确定性"。 自适应的前提永远是"背景可感知、可预测";不可控背景一来,这个前提就破,自适应会朝着不可读的方向漂。

而且要记住对比度是分层的:每多一层会自适应的中间材质,账就多一层,你就得多钉死一层,否则相邻两层的自适应会反向互殴。设计这类界面时,与其逐个调颜色,不如先问一句:这条视觉链路上有几层在自适应?我把它们都钉成确定的了吗?