卷 VII · 表面· CH 25· 真高度引擎 深度 25/28

光从上面

前面二十四章一直在二维里打转。这一卷补上第三个维度——不是为了做 3D 效果,是因为「哪个在上面」本身就是一种层级信息,而且是最不费力的一种。

色调高度的真公式阴影是两层凸起 vs 凹陷

一条不用记的规矩

整个「深度」话题可以压成一句话,而且它来自物理而不是审美:

光从上面来

太阳在天上,灯在天花板上。人类视觉系统对此有硬编码的假设——它默认光源在上方

于是:

  • 凸起的东西:上沿更亮(迎光),下方有影子。
  • 凹陷的东西:上沿更暗(被自己的边挡住),下沿更亮。

这就是为什么按钮看起来能按、输入框看起来能填——而你把上下的明暗反过来,同一个形状会立刻从「凸」变成「凹」。

「扁平化」拿掉的是拟物的材质(皮革、金属、渐变),不是这条规律。看今天任何一个成熟界面:卡片下面还是有影子,输入框还是比背景暗一点。

Material 3 的做法:不靠阴影

这里有一件很多人不知道的事:Material 3 表达高度的主力手段不是阴影。

把表面往主色的方向染一点——越高染得越多。公式直接来自 Compose 源码:

alpha = (4.5 × ln(dp + 1) + 2) / 100
结果 = surfaceTint 以该不透明度叠在 surface 上

把这个公式跑一遍 M3 的六个高度层级:

Leveldp叠加不透明度亮色表面暗色表面
L000%#FFFBFF#1C1B1E
L115.12%#F7F2FA#25232A
L238.24%#F2EDF8#2B2831
L3610.76%#EFE9F5#2F2C36
L4811.89%#EDE7F4#312E39
L51213.54%#EAE4F3#34313C

那五个百分比——5.12 / 8.24 / 10.76 / 11.89 / 13.54——正是 Material 文档里印的「5% 8% 11% 12% 14%」。不是巧合,那张表就是这么算出来的。

为什么要这么绕,不直接用阴影

因为暗色模式下阴影是看不见的。

阴影靠的是「比周围暗」。在一个 #1C1B1E 的背景上,你没法再暗多少——投影融进背景里,高度信息全丢了。

染色则两边都有效:亮色模式下往暗里走,暗色模式下往亮里走,都是「离你更近的表面更显眼」

顺带,这也解释了「为什么暗色模式的卡片背景不是纯黑」——它是 surface 加了一层 tint。

阴影是两层的

阴影仍然有用(亮色模式下它比染色更直观),但有一个几乎所有人都做错的地方:只写一层阴影。

《Refactoring UI》讲得最好的一点就在这儿。真实的阴影是两部分:

接触影 contact紧、深、几乎不偏移 —— 说「它落在这儿」
环境影 ambient大、淡、偏移明显 —— 说「它离得多远」

关键在于这两层的比例会随高度变化。把 Demo 切到「阴影」模式,拉一下 Level,看「接触影占比」这一格:

高度接触影模糊环境影模糊接触影占比
1dp1.01.976%
3dp2.15.770%
6dp2.911.462%
12dp4.222.848%
24dp5.945.626%
低的时候在说「贴着」,高的时候在说「飘着」

只用一层阴影的界面看起来「假」,就是因为丢了这个此消彼长——一个 24dp 的弹窗和一个 1dp 的卡片,用的是同一种影子,只是更大更黑。

而在现实里,它们的影子性质是不同的:贴着桌面的纸有一圈很紧的暗边,飘在半空的纸没有那圈边,只有一大片淡影。

半影的宽度是能算的,而且公式很朴素:

半影宽度 ≈ 离面高度 × (光源半径 / 光源距离)

点光源(半径为 0)投出的影子边缘是硬的。现实里没有点光源,所以影子有半影,而半影宽度正比于高度——这就是「越高影子越糊」的全部原因。

高度也是一套阶梯

和字号、间距一样,高度也必须收成有限几档。第 1 章数出来的投影有 7 种(1、2、3、4、6、8、24),Material 给的是 6 档,而你实际用得上的通常只有三档

层级Level谁在这一层
贴着背景L0页面本身、列表项、大部分内容
浮起一点L1–L2卡片、顶栏(滚动时)、搜索框
盖在上面L3–L5菜单、对话框、底部弹层、FAB
一屏上超过两个高度层级,用户就分不清了

高度和字号一样:层级需要可辨认的差别。L1 和 L2 之间的表面色差只有 ΔE 0.015,肉眼几乎看不出来。

所以别指望用「L1 的卡片 + L2 的卡片」表达什么——用户只会看到两张卡片。要么让它们一样,要么拉开到 L1 vs L4。

// Material 3 的组件已经内置了高度,优先用默认值
Card(
    elevation = CardDefaults.cardElevation(
        defaultElevation = 1.dp,     // L1
        pressedElevation = 3.dp      // 按下时浮起 —— 这也是第 17 章的「点了有反应」
    )
) { … }

// 需要自己算表面色时(比如自定义容器),别手写那个百分比
val surfaceAt3 = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
Box(Modifier.background(surfaceAt3)) { … }

// M3 还直接给了五档语义化的表面色,通常这个就够
MaterialTheme.colorScheme.surfaceContainerLowest   // L0
MaterialTheme.colorScheme.surfaceContainerLow      // L1
MaterialTheme.colorScheme.surfaceContainer         // L2
MaterialTheme.colorScheme.surfaceContainerHigh     // L3
MaterialTheme.colorScheme.surfaceContainerHighest  // L4

// ✗ 别这么写 —— 一层阴影,而且是随手定的数
Modifier.shadow(elevation = 5.dp)
本章关掉的自由度本章 3 条 · 累计 70 / 78
  • 「凸起还是凹陷怎么画」——光从上面来。凸起:上沿亮、下方有影;凹陷反过来。不再靠试。
  • 「高度用几 dp」——从 M3 的六档里取(0/1/3/6/8/12),一屏最多用两个层级。表面色用 surfaceContainer*,不手写不透明度。
  • 「阴影怎么配」——两层:紧的接触影 + 大的环境影,且比例随高度变。别只写一层。