留白到底归谁管
前三章讲了留多少。这一章讲一个更容易出事的问题:这段留白是谁给的。答错了,你会得到一个三个月后没人敢改的界面。
先看一个能把人绕进去的例子
Compose 的 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 没有 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 个自由度。回头看,这一卷的所有内容其实可以压成三句话:
如果你只改这三样,界面已经会像样一大截——而且到现在为止,我们一个颜色、一个字号都还没碰。
- 「padding 写在 background 前还是后」——留白要在色块里面,就写在 background 后面。
clickable同理,写在 padding 后面才能吃到整块的点击区。 - 「这段间距写在哪个元素上」——容器。子元素永远不带外边距,于是它随便复用。
- 「列表的首尾留白怎么给」——
contentPadding,不是外面套一层 padding。横滑列表尤其。