间距在说「这两个是不是一伙的」
如果这本书只能留一章,是这一章。间距是所有维度里最便宜改、见效最快的一个——而且它根本不是「留白多少好看」的问题,它是语法。
一个不需要解释的实验
看这两行点:
A ● ● ● ● ● ● ● ● B ● ● ● ● ● ● ● ●
A 行你读到的是「三组」。B 行你读到的是「八个点」。
点是一样的点,数量是一样的数量,颜色大小全都一样。唯一的区别是间距。
这就是格式塔心理学里的接近性原则:靠得近的东西会被知觉自动归为一组。它不需要你同意,也不需要你注意——它在你意识到之前就完成了。
间距不是装饰,是语法。
你在两个元素之间放多少距离,等于在说一句话:「这俩是一伙的」或者「这俩不是一伙的」。这句话用户一定会听见,而且会当真——哪怕你根本没打算说。
算出来给你看
「眼睛会自动分组」听起来还是有点玄。所以下面这台引擎用一个算法把它复现了一遍:
单链聚类——把相邻元素之间的间隙全部列出来,排个序,然后在「相邻两个间隙差得最多」的地方切一刀。切完,剩下的就是组。
这个算法不知道什么是设计,也不知道什么是格式塔。它只知道 28 比 8 大得多。而它给出的分组,和你眼睛给出的分组是同一个。
试三件事:
- 把组内和组外调成一样 → 算法找不到该在哪儿下刀,只剩一整团。你的眼睛也一样。这就是第 4 章说的「散」。
- 把组外调到组内的 1.5 倍 → 分开了,但跳变比很小,读起来要愣一下。
- 把组外调到组内的 2 倍以上 → 干净利落,不用想。
这是全书最好用的一条规则,因为它是比值,不是绝对值。
它不关心你的组内间距是 8 还是 12,只要求组外至少是它的两倍。所以它在任何密度的界面上都成立——密集的表格可以 4 : 8,宽松的落地页可以 24 : 48。
反过来说:组内间距和组外间距一样宽,等于你的分组不存在。无论你在代码里怎么用 Column 嵌套 Column,用户看到的都是一堆平铺的东西。
最常犯的错:间距写在错的地方
知道了规则,来看为什么大家还是会写错。看这段:
Column {
Text(title, Modifier.padding(bottom = 8.dp))
Text(subtitle, Modifier.padding(bottom = 16.dp))
Text(price, Modifier.padding(bottom = 8.dp))
Text(desc, Modifier.padding(bottom = 16.dp))
Button(...)
}
每个元素自己带一个 bottom padding。看起来很合理,实际有三个问题:
- 间距的归属不清。标题和副标题之间那 8dp,是标题给的。那如果这个标题被复用到另一个地方呢?它会把 8dp 带过去。
- 最后一个元素多了一段。
desc的 16dp bottom 会在 Button 上面留一段,但 Button 下面就没有了——除非 Column 自己再补一个 padding,于是又多了一处。 - 看不出分组意图。读代码的人要在脑子里把 8、16、8、16 排出来,才能推断出「哪几个是一组」。
间距由每个子元素自己带,四处散落。
- 复用时会把间距带走
- 首尾会多出或缺少一段
- 分组意图埋在数字里
间距由容器统一提供,分组用嵌套表达。
- 子元素干净,随便复用
- 首尾对称,不用打补丁
- 看代码结构就能看出分组
// 用 Arrangement.spacedBy 让容器管间距,
// 分组直接用嵌套 Column 表达 —— 代码结构 == 视觉结构
Column(
verticalArrangement = Arrangement.spacedBy(Spacing.groupGap), // 组外 24
modifier = Modifier.padding(Spacing.screenPadding)
) {
// 第一组:标题区
Column(verticalArrangement = Arrangement.spacedBy(Spacing.tight)) { // 组内 8
Text(title, style = MaterialTheme.typography.headlineSmall)
Text(subtitle, style = MaterialTheme.typography.bodyMedium)
}
// 第二组:价格区
Column(verticalArrangement = Arrangement.spacedBy(Spacing.tight)) {
Text(price, style = MaterialTheme.typography.titleLarge)
Text(desc, style = MaterialTheme.typography.bodySmall)
}
Button(onClick = onBuy) { Text("立即购买") }
}
注意这里发生的事:「组内 8 / 组外 24」这条设计规则,变成了代码里两个不同的常量名。它不再是散落在四处的数字,而是一个可以一眼看懂、也可以一次改完的结构。
顺带,这也是为什么 Arrangement.spacedBy 比给每个孩子加 padding 好:它只在元素之间放间距,首尾不会多出来。
间距是一棵树
把上面的想法推到底,你会得到一个很有用的心智模型:
屏幕边距 > 大区块之间 > 组之间 > 组内元素之间 > 元素内部(比如图标和文字之间)。
这是一棵树,而且越往里,间距越小。如果你的间距不是单调递减的——比如组内 16、组之间 12——那么用户读出来的分组,和你代码里写的分组是相反的。
用这本书的间距阶梯举例,一个典型的层次是:
注意 24 和 8 之间的比是 3 : 1,远超过 2 : 1 的下限。这不是浪费——层次越多,每一层的比值就要留得越足,否则中间那几层会糊在一起。
从太多留白开始,往回收
这是《Refactoring UI》里一条关于方向的建议,短,但改的是习惯:
而不是先挤在一起,再往外加。
原因很实在:从挤往开加,你永远加不够。
因为每一次「加一点」都要克服一次心理阻力——屏幕就这么大,加留白等于「浪费」。于是你每次只敢加 2dp,加到 10dp 就停了,而对的答案是 24dp。
反过来做就没有这个问题:先给一个夸张的间距(比如 48dp),然后一档一档往回收,收到「再收就糊了」的前一档。你会发现停下来的地方,比你从下面加上去的高得多。
这条建议对开发者尤其有用,因为我们的默认起点几乎总是「零间距」——你写 Column { ... },什么都不加,孩子们就是贴着的。贴着不是中性的起点,它是一个极端。
一个很常见的追问
「我的界面就是很挤,塞不下这么多留白怎么办?」
答案是:那就把所有间距整体缩小,但比值不能动。
4 : 8 : 16 和 8 : 16 : 32 表达的是同一个结构,只是密度不同。真正会毁掉分组的,是把它压成 8 : 10 : 12——那时候你保住了「有间距」,却丢掉了「间距在说什么」。
空间不够的时候,按这个顺序砍:
- 先砍元素——第 2 章那一问,三问都答不上的先删。
- 再砍最外层的间距——屏幕边距从 16 降到 12,视觉损失最小。
- 整体等比缩——所有间距乘 0.75,比值不变。
- 最后才动比值——而且一旦动了,你就要接受分组会变模糊。
大多数人的做法正好相反:从第 4 步开始,一处一处地抠。抠到最后间距全变成 10、11、12,第 1 章那 24 个间距就是这么来的。
- 「这儿留多少白」——先问「这俩是不是一伙的」,是就用组内值,不是就用组外值。留白量是分组判断的结果,不是审美判断的结果。
- 「间距写在哪个元素上」——永远写在容器上(
Arrangement.spacedBy/ 容器 padding),不写在子元素上。子元素保持干净,随便复用。