卷 II · 距离· CH 05· 真单链聚类 深度 5/28

间距在说「这两个是不是一伙的

如果这本书只能留一章,是这一章。间距是所有维度里最便宜改、见效最快的一个——而且它根本不是「留白多少好看」的问题,它是语法。

格式塔接近性组内 : 组外 ≥ 1 : 2单链聚类

一个不需要解释的实验

看这两行点:

A    ● ● ●   ● ● ●   ● ●

B    ●  ●  ●  ●  ●  ●  ●  ●

A 行你读到的是「三组」。B 行你读到的是「八个点」。

点是一样的点,数量是一样的数量,颜色大小全都一样。唯一的区别是间距。

这就是格式塔心理学里的接近性原则:靠得近的东西会被知觉自动归为一组。它不需要你同意,也不需要你注意——它在你意识到之前就完成了。

这一卷的主线

间距不是装饰,是语法。

你在两个元素之间放多少距离,等于在说一句话:「这俩是一伙的」或者「这俩不是一伙的」。这句话用户一定会听见,而且会当真——哪怕你根本没打算说

算出来给你看

「眼睛会自动分组」听起来还是有点玄。所以下面这台引擎用一个算法把它复现了一遍:

单链聚类——把相邻元素之间的间隙全部列出来,排个序,然后在「相邻两个间隙差得最多」的地方切一刀。切完,剩下的就是组。

这个算法不知道什么是设计,也不知道什么是格式塔。它只知道 288 大得多。而它给出的分组,和你眼睛给出的分组是同一个

试三件事:

  • 把组内和组外调成一样 → 算法找不到该在哪儿下刀,只剩一整团。你的眼睛也一样。这就是第 4 章说的「散」。
  • 把组外调到组内的 1.5 倍 → 分开了,但跳变比很小,读起来要愣一下。
  • 把组外调到组内的 2 倍以上 → 干净利落,不用想。
规则:组内 : 组外 ≥ 1 : 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。看起来很合理,实际有三个问题:

  1. 间距的归属不清。标题和副标题之间那 8dp,是标题给的。那如果这个标题被复用到另一个地方呢?它会把 8dp 带过去。
  2. 最后一个元素多了一段。desc 的 16dp bottom 会在 Button 上面留一段,但 Button 下面就没有了——除非 Column 自己再补一个 padding,于是又多了一处。
  3. 看不出分组意图。读代码的人要在脑子里把 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——那么用户读出来的分组,和你代码里写的分组是相反的。

用这本书的间距阶梯举例,一个典型的层次是:

屏幕左右边距16 dp
大区块之间32 dp
组之间24 dp
组内元素之间8 dp
图标和它的文字之间4 dp

注意 24 和 8 之间的比是 3 : 1,远超过 2 : 1 的下限。这不是浪费——层次越多,每一层的比值就要留得越足,否则中间那几层会糊在一起。

从太多留白开始,往回收

这是《Refactoring UI》里一条关于方向的建议,短,但改的是习惯:

先留得太开,再往回收

而不是先挤在一起,再往外加。

原因很实在:从挤往开加,你永远加不够。

因为每一次「加一点」都要克服一次心理阻力——屏幕就这么大,加留白等于「浪费」。于是你每次只敢加 2dp,加到 10dp 就停了,而对的答案是 24dp。

反过来做就没有这个问题:先给一个夸张的间距(比如 48dp),然后一档一档往回收,收到「再收就糊了」的前一档。你会发现停下来的地方,比你从下面加上去的高得多。

这条建议对开发者尤其有用,因为我们的默认起点几乎总是「零间距」——你写 Column { ... },什么都不加,孩子们就是贴着的。贴着不是中性的起点,它是一个极端。

一个很常见的追问

「我的界面就是很挤,塞不下这么多留白怎么办?」

答案是:那就把所有间距整体缩小,但比值不能动。

4 : 8 : 16 和 8 : 16 : 32 表达的是同一个结构,只是密度不同。真正会毁掉分组的,是把它压成 8 : 10 : 12——那时候你保住了「有间距」,却丢掉了「间距在说什么」。

压缩的正确顺序

空间不够的时候,按这个顺序砍:

  1. 先砍元素——第 2 章那一问,三问都答不上的先删。
  2. 再砍最外层的间距——屏幕边距从 16 降到 12,视觉损失最小。
  3. 整体等比缩——所有间距乘 0.75,比值不变。
  4. 最后才动比值——而且一旦动了,你就要接受分组会变模糊。

大多数人的做法正好相反:从第 4 步开始,一处一处地抠。抠到最后间距全变成 10、11、12,第 1 章那 24 个间距就是这么来的。

本章关掉的自由度本章 2 条 · 累计 15 / 78
  • 「这儿留多少白」——先问「这俩是不是一伙的」,是就用组内值,不是就用组外值。留白量是分组判断的结果,不是审美判断的结果。
  • 「间距写在哪个元素上」——永远写在容器上(Arrangement.spacedBy / 容器 padding),不写在子元素上。子元素保持干净,随便复用。