光从上面来
前面二十四章一直在二维里打转。这一卷补上第三个维度——不是为了做 3D 效果,是因为「哪个在上面」本身就是一种层级信息,而且是最不费力的一种。
一条不用记的规矩
整个「深度」话题可以压成一句话,而且它来自物理而不是审美:
太阳在天上,灯在天花板上。人类视觉系统对此有硬编码的假设——它默认光源在上方。
于是:
- 凸起的东西:上沿更亮(迎光),下方有影子。
- 凹陷的东西:上沿更暗(被自己的边挡住),下沿更亮。
这就是为什么按钮看起来能按、输入框看起来能填——而你把上下的明暗反过来,同一个形状会立刻从「凸」变成「凹」。
「扁平化」拿掉的是拟物的材质(皮革、金属、渐变),不是这条规律。看今天任何一个成熟界面:卡片下面还是有影子,输入框还是比背景暗一点。
Material 3 的做法:不靠阴影
这里有一件很多人不知道的事:Material 3 表达高度的主力手段不是阴影。
是把表面往主色的方向染一点——越高染得越多。公式直接来自 Compose 源码:
alpha = (4.5 × ln(dp + 1) + 2) / 100 结果 = surfaceTint 以该不透明度叠在 surface 上
把这个公式跑一遍 M3 的六个高度层级:
| Level | dp | 叠加不透明度 | 亮色表面 | 暗色表面 |
|---|---|---|---|---|
| L0 | 0 | 0% | #FFFBFF | #1C1B1E |
| L1 | 1 | 5.12% | #F7F2FA | #25232A |
| L2 | 3 | 8.24% | #F2EDF8 | #2B2831 |
| L3 | 6 | 10.76% | #EFE9F5 | #2F2C36 |
| L4 | 8 | 11.89% | #EDE7F4 | #312E39 |
| L5 | 12 | 13.54% | #EAE4F3 | #34313C |
那五个百分比——5.12 / 8.24 / 10.76 / 11.89 / 13.54——正是 Material 文档里印的「5% 8% 11% 12% 14%」。不是巧合,那张表就是这么算出来的。
因为暗色模式下阴影是看不见的。
阴影靠的是「比周围暗」。在一个 #1C1B1E 的背景上,你没法再暗多少——投影融进背景里,高度信息全丢了。
染色则两边都有效:亮色模式下往暗里走,暗色模式下往亮里走,都是「离你更近的表面更显眼」。
顺带,这也解释了「为什么暗色模式的卡片背景不是纯黑」——它是 surface 加了一层 tint。
阴影是两层的
阴影仍然有用(亮色模式下它比染色更直观),但有一个几乎所有人都做错的地方:只写一层阴影。
《Refactoring UI》讲得最好的一点就在这儿。真实的阴影是两部分:
关键在于这两层的比例会随高度变化。把 Demo 切到「阴影」模式,拉一下 Level,看「接触影占比」这一格:
| 高度 | 接触影模糊 | 环境影模糊 | 接触影占比 |
|---|---|---|---|
| 1dp | 1.0 | 1.9 | 76% |
| 3dp | 2.1 | 5.7 | 70% |
| 6dp | 2.9 | 11.4 | 62% |
| 12dp | 4.2 | 22.8 | 48% |
| 24dp | 5.9 | 45.6 | 26% |
只用一层阴影的界面看起来「假」,就是因为丢了这个此消彼长——一个 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)
- 「凸起还是凹陷怎么画」——光从上面来。凸起:上沿亮、下方有影;凹陷反过来。不再靠试。
- 「高度用几 dp」——从 M3 的六档里取(0/1/3/6/8/12),一屏最多用两个层级。表面色用
surfaceContainer*,不手写不透明度。 - 「阴影怎么配」——两层:紧的接触影 + 大的环境影,且比例随高度变。别只写一层。