卷 II · 距离· CH 08· Modifier 顺序 深度 8/28

留白到底归谁管

前三章讲了留多少。这一章讲一个更容易出事的问题:这段留白是谁给的。答错了,你会得到一个三个月后没人敢改的界面。

Modifier 是有顺序的留白属于容器那 14px 是谁给的

先看一个能把人绕进去的例子

Compose 的 Modifier有顺序的——它不是一袋属性,是一条从外往里的加工链。下面这两行,看起来只是换了个位置:

结论一句话:

Modifier 链是从外往里走的

写在前面的 modifier 更靠

  • .padding().background()——先缩进,再铺色。色块变小了,留白在色块外面。
  • .background().padding()——先铺色,再缩进。色块铺满,留白在色块里面,这才是设计稿上那个卡片。

同样的道理适用于 .clickable():写在 padding 前面,点击区只有内容那么大;写在后面,整块(含留白)都能点。第 19 章会告诉你这直接决定了触摸目标够不够 48dp。

真正的规则:留白属于容器

上面那个只是语法。真正要立的规矩在这里:

一段留白,只能有一个提供者,而且应该是容器

一个列表项上下的 16dp,应该由列表项自己的 padding 提供,而不是由标题的 marginTop 加上正文的 marginBottom 凑出来。

违反它的代价是:三个月后你想把这个间距从 16 改成 24,得先花二十分钟搞清楚那 16 是从哪儿来的——有可能是两个 8 加起来的,也有可能是一个 20 减去一个负 4。

这条规矩有一个很好用的推论,能直接拿去 code review:

随手定

子元素自带外边距。

// 组件内部
Column {
  Text(title,
    Modifier.padding(top = 12.dp))
  ...
}

这个组件不能被复用——它会把 12dp 带到每一个用它的地方。

有出处

子元素不带任何外边距。

// 组件内部:干净
Column { Text(title); ... }

// 用它的地方决定间距
Column(
  verticalArrangement =
    Arrangement.spacedBy(Spacing.group)
) { Card(); Card() }

组件只管自己内部,外部关系由外部决定

为什么这条在 Compose 里比在 XML 时代更重要

因为 Compose 没有 margin

这不是遗漏,是故意的——Compose 的设计者认为「外边距」这个概念本身就是错的:一个元素不该知道它周围应该留多少空。它只该知道自己内部长什么样。

所以当你发现自己在给一个 Composable 加 Modifier.padding(top = ...) 来「和上面那个拉开」时,你正在用 padding 模拟 margin——而 Compose 拿掉 margin 就是为了拦住你。正确做法是把这个决定交给父容器的 Arrangement.spacedBy

一个组件该暴露 padding 参数吗

这是实践中最常纠结的一处。答案分情况,而且有一条清晰的线:

哪种留白归谁要不要做成参数
内部留白(卡片内容离卡片边多远) 组件自己 不要。这是组件身份的一部分,暴露出去就没人能保证卡片长得一样了
外部留白(这张卡片和下一张离多远) 调用方 不要做成参数,做成 modifier 参数即可——调用方自己用 Arrangement
内容区留白(可滚动列表的首尾) 调用方 。用 contentPadding,因为它必须能被滚动出去
// 每个公开 Composable 的第一个可选参数固定是 modifier —— 这是官方约定
@Composable
fun OrderCard(
    order: Order,
    modifier: Modifier = Modifier,      // 外部关系交给调用方
    onClick: () -> Unit
) {
    Card(
        onClick = onClick,
        modifier = modifier.fillMaxWidth()      // 先接上调用方的,再加自己的
    ) {
        Column(
            // 内部留白写死,这是「OrderCard 长什么样」的一部分
            modifier = Modifier.padding(Spacing.base),
            verticalArrangement = Arrangement.spacedBy(Spacing.tight)
        ) { ... }
    }
}

// contentPadding:列表首尾的留白必须能跟着一起滚
LazyColumn(
    contentPadding = PaddingValues(
        horizontal = Spacing.base,
        vertical = Spacing.group
    ),
    verticalArrangement = Arrangement.spacedBy(Spacing.snug)
) { items(orders) { OrderCard(it, onClick = { ... }) } }

注意 modifier = modifier.fillMaxWidth() 这个顺序:调用方传进来的永远在最外层。写反了(Modifier.fillMaxWidth().then(modifier))调用方就没法覆盖你的行为。

为什么 contentPadding 要单独说

因为这是最容易做错、而且做错了很难受的一个:

// ✗ 错:留白在列表外面 —— 滚动时内容会在留白处被切掉,
//       而且滚动条也缩进去了
Box(Modifier.padding(16.dp)) {
    LazyColumn { ... }
}

// ✓ 对:留白在列表里面 —— 内容能滚到留白区,
//       滚动条贴着屏幕边
LazyColumn(contentPadding = PaddingValues(16.dp)) { ... }

差别在真机上很明显:错的写法,你往上滑的时候,第一个卡片会在离屏幕顶 16dp 的地方被硬生生切断;对的写法,它会一路滑到屏幕边缘才消失。

顺带一个常见的取舍

横向列表(比如首页的横滑卡片)要不要给 contentPadding

要,而且这是横滑区最重要的一个细节。给了之后,第一张卡片会从 16dp 处开始,而最后一张卡片右边也有 16dp——否则最后一张会贴着屏幕右边,看起来像被截断了。

更进一步:很多 App 会让横滑区露出下一张卡片的一小条(大约 16–24dp),这是在回答第 2 章的「我能做什么」——它在说「右边还有,可以滑」。

卷 II 收尾

四章下来,距离这个维度关掉了 11 个自由度。回头看,这一卷的所有内容其实可以压成三句话:

留多少组内 : 组外 ≥ 1 : 2,值从阶梯取
落在哪8 的倍数;图标按视觉质心补偿
谁给的容器给,子元素永远不带外边距

如果你只改这三样,界面已经会像样一大截——而且到现在为止,我们一个颜色、一个字号都还没碰。

本章关掉的自由度本章 3 条 · 累计 24 / 78
  • 「padding 写在 background 前还是后」——留白要在色块里面,就写在 background 后面。clickable 同理,写在 padding 后面才能吃到整块的点击区。
  • 「这段间距写在哪个元素上」——容器。子元素永远不带外边距,于是它随便复用。
  • 「列表的首尾留白怎么给」——contentPadding,不是外面套一层 padding。横滑列表尤其。