外观
布局与 Modifier
Compose 里的尺寸、间距、背景范围、命中区,全由 Modifier 链的顺序决定。顺序写错,代码照样编译、照样跑,界面就是差一点:背景比卡片小一圈、水波纹从圆角外漏出来、16dp 内边距点不动。
骨架看 7 UI 设计和业务开发,组件从 UI 组件库 挑,这一页只管链。下文用 bg 代指 MaterialTheme.colorScheme.surfaceContainerLow、用 shape 代指从 AppShapes 取出来的圆角 token(不自己写 RoundedCornerShape)。
一、链的顺序决定一切
三条规则:
- 靠前的在外层,靠后的在内层。
Modifier.a().b()读作「a 包住 b」。 - 外层先定尺寸,内层只能缩小不能长大。先
size(100.dp)再padding(16.dp),padding 撑不出去。 - 每个修饰符只影响自己和内层。
clip裁不到排在它前面的背景,clickable管不到排在它前面的 padding。
| 类别 | 修饰符 | 影响什么 |
|---|---|---|
| 测量 | padding size fillMaxWidth widthIn weight aspectRatio | 占多大、往下传什么约束 |
| 绘制 | background border clip graphicsLayer | 画在哪、画多大、裁不裁 |
| 手势 | clickable combinedClickable pointerInput | 哪块矩形收事件 |
整体固定顺序,按这个排基本不会错:尺寸约束 → clip → background → border → clickable → padding。先定占多大,再裁形状,再铺背景,再描边,再决定哪块能点,最后是内容内边距。下面每一节的坑,都是把其中两个换了位置。
1.1 padding 与 background:背景画到哪
kotlin
// ❌ 背景被 padding 挤小:占位 132×132,背景色只有中间 68×68
Box(Modifier.background(c).padding(16.dp).size(100.dp))
// ✅ 背景铺满整块:占位 100×100 全是背景色,内容内缩到 68×68
Box(Modifier.size(100.dp).padding(16.dp).background(c))| 链 | 元素占位 | 背景覆盖 | 语义 |
|---|---|---|---|
background().padding().size() | 132×132 | 整块 132×132 | 背景在外,padding 是内边距 |
size().padding().background() | 100×100 | 只有 68×68 | background 在内,padding 变成外边距 |
Compose 没有 margin:外边距就把 padding 写在 background / clickable 前面(外层),内边距就写在后面(内层)。两者都要就在链上写两段——.padding(horizontal = 16.dp).clip(shape).background(bg).padding(16.dp) 分别是「离屏幕 16dp」「背景只在这块里」「内容离卡片边 16dp」。
1.2 clip:只裁内层
clip 不改尺寸,只改绘制,不会逆向影响排在前面的修饰符。
kotlin
// ✅ 圆角背景:clip 在前,background 在内层,四角被裁掉
Box(Modifier.clip(shape).background(bg))
// ❌ 圆角失效:background 先画成方角,clip 只裁得到内层内容
Box(Modifier.background(bg).clip(shape))clip 只影响绘制,不影响命中
被圆角裁掉的四个角仍然可以点——命中判定走矩形边界,要真正缩窄可点区域只能自己判断。圆角要裁的还有子内容:方图放进圆角容器,不加 .clip(shape) 就是方的。
1.3 clickable:命中区由 padding 的位置决定
clickable 收到多大一块矩形,取决于排在它前面的东西给了它多大。
kotlin
// ✅ 整块(含 16dp 内边距)都能点,波纹铺满整块
Box(Modifier.fillMaxWidth().clip(shape).background(bg)
.clickable(onClick = onClick).padding(16.dp))
// ❌ 只把 padding 挪到 clickable 前面:那圈 16dp 变成死区,波纹缩在内容里
Box(Modifier.fillMaxWidth().padding(16.dp).clip(shape).background(bg)
.clickable(onClick = onClick))| 链 | 背景范围 | 命中范围 | 水波纹 |
|---|---|---|---|
padding(16).background().clickable() | 内缩 16dp | 内缩 16dp | 缩在内容里,点不实 |
clip().background().clickable().padding(16) | 整块(圆角) | 整块(四角也能点) | 铺满 + 圆角裁齐 |
background().clickable().clip(shape) | 方角 | 整块 | 方的,从圆角外冒出来 |
最后一行是最常见的视觉 bug:clickable 在 clip 外面,波纹由 clickable 自己画,内层的 clip 管不到它。
clickable 默认走主题里的 LocalIndication,也就是水波纹,不用手传 indication,更不要传 null(按下去毫无反馈)。只有要观察按压态时才自己 remember { MutableInteractionSource() } 传给 clickable,再用 collectIsPressedAsState() 驱动背景色——别拿 pressed 去换整条 Modifier 链。
1.4 border:shape 必须和背景同一个
kotlin
// ✅ 背景与描边共用 shape,中间不夹 padding
Modifier
.clip(shape)
.background(bg)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)border 不裁剪内容:圆角容器里放方图,只加 border 没用,图还是方的,必须 .clip(shape)。背景和描边之间夹了 padding 还会让两者轮廓错开——谁在外,谁的矩形就更大。
1.5 size / fillMaxWidth 的位置
kotlin
Modifier.size(100.dp).padding(16.dp) // 占位 100,内容 68
Modifier.padding(16.dp).size(100.dp) // 占位 132,内容 100
Modifier.fillMaxWidth().background(c).clickable { }.padding(16.dp) // 整块宽都能点
Modifier.padding(16.dp).background(c).clickable { } // 内缩后那块才能点两种 fillMaxWidth 写法占位宽度可能一样,差别全在背景、命中区、水波纹铺到哪。
二、常用组合套路
2.1 可点卡片:整块可点 + 波纹铺满 + 圆角裁齐
kotlin
@Composable
fun MealCard(
shape: Shape, // 调用方从 AppShapes 取
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier.fillMaxWidth()
.clip(shape) // ① 形状
.background(MaterialTheme.colorScheme.surfaceContainerLow) // ② 底色(吃到圆角)
.clickable(onClick = onClick) // ③ 命中 = 整块
.padding(16.dp), // ④ 内容内边距
) { content() }
}一句话:clip 管形状、background 管底色、clickable 管命中、padding 管内容,一层层收窄。
2.2 触控目标至少 48dp
视觉可以小,命中区不能小:.sizeIn(minWidth = 48.dp, minHeight = 48.dp) 排在 clickable 之前,把命中区撑到 48dp,视觉块照旧 20dp。ListItem / FilledButton / ActionImageButton 已经满足,不用你管。
只想让文字可点、外围留白不响应,就把 clickable 写在 padding 内层(Modifier.padding(16.dp).clickable { })。多数情况别这么干——整行可点才对,组件库 ListItem 已经封好了。
2.3 组件库封好的链,别重复排
| 你要的 | 用 | 别做 |
|---|---|---|
| 主次动作按钮 | FilledButton / TonalButton / OutlinedButton | 自己 Row + clip + clickable 拼 |
| 图标按钮 | ActionImageButton | 自己包 Box 加 clickable |
| 可点列表行 | ListItem | 自己拼 Row 再补 clickable |
| 可点标签 | PillTab / PillLabel | 自己写圆角 + 选中态背景 |
| 分隔线 | HorizontalDivider | 自己拿 Box 画 1dp 线 |
| 标题栏 | TopTitleScaffold | 自己拼标题行 + statusBarsPadding() |
这些组件内部已经把 clip / background / clickable / 最小触控尺寸的先后排好了。你只用 modifier 参数给它加外边距,别在外面再套一层 clickable——会变成两层点击区、两次波纹。
三、Row / Column / Box 的对齐与 weight
| 容器 | 主轴排列 | 交叉轴对齐 | 参数 |
|---|---|---|---|
Row | 水平 | 垂直 | horizontalArrangement / verticalAlignment |
Column | 垂直 | 水平 | verticalArrangement / horizontalAlignment |
Box | — | 层叠 | contentAlignment |
主轴用 Arrangement:Start / End / Center 靠头靠尾居中、SpaceBetween 两端顶死中间均分、Arrangement.spacedBy(8.dp) 固定间距——组内间距首选 spacedBy。
kotlin
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
ActionImageButton(icon = Phosphor.CaretRight, onAction = { onOpen() })
}3.1 weight:分配剩余空间
语义是先扣掉没有 weight 的孩子,剩余空间再按比例分,只在 Row / Column 的 scope 内可用。
| 写法 | 含义 |
|---|---|
weight(1f) | 拿到份额并铺满(默认 fill = true) |
weight(2f) | 拿 2 份 |
weight(1f, fill = false) | 份额当上限:内容小就按内容尺寸,空出来的地方不回收 |
在 Row 里分的是宽,在 Column 里分的是高。没有 weight 的孩子先量,所以「固定图标 + weight(1f) 文本」最稳。
3.2 weight 与 fillMaxWidth 的区别
fillMaxWidth() | weight(1f) | |
|---|---|---|
| 尺寸来源 | 父给的 max 约束,能占多少占多少 | 父先量完其他孩子,剩余按比例分 |
| 作用轴 | 永远是宽 | 主轴(Row 宽 / Column 高) |
| 谁先吃 | 先被量到就先吃光 | 后量,可能分到 0 |
因此同一个 Row 里别同时用两者:Row 先量没有 weight 的孩子,写了 fillMaxWidth() 的孩子会把整行宽度吃光,随后 weight(1f) 的孩子拿到的剩余空间是 0,直接不显示。
四、尺寸约束
| 效果 | 用什么 | 坑 |
|---|---|---|
| 横向撑满可用宽 | fillMaxWidth() / fillMaxWidth(0.8f) | 位置决定撑的是整块还是内缩后的块 |
| 撑满父 / 由内容决定 | fillMaxSize() / 不写 / wrapContentSize() | fillMaxSize 在无界滚动容器里高度还是内容高;wrapContentSize 在紧约束下把内容包起来按 align 摆,容器不一定缩 |
| 只给上下限 | widthIn / heightIn / sizeIn | 和 size 同用时靠外的那个先说话 |
| 有最小高度但不封顶 | defaultMinSize(minHeight = 48.dp) | 组件作者的正确写法,见 4.1 |
| 无视父约束 | requiredWidth / requiredHeight | 会画出父容器,列表项里会串版 |
| 兄弟对齐成同一尺寸 | IntrinsicSize.Min / Max | 有测量代价,Lazy / SubcomposeLayout 里抛异常 |
| 固定宽高比 | aspectRatio(16f / 9f) | 至少一个轴要有界 |
size接受父约束:父只给 200dp 时size(300.dp)被压回 200;requiredSize(300.dp)不会,直接画出界。widthIn(max = 200.dp).size(300.dp)→ 200,反过来写掰不动外层已经定死的尺寸;aspectRatio至少一个轴要有界。- 想居中一层内容用
Box(contentAlignment = Alignment.Center),wrapContentSize(align)干的是另一件事。
4.1 组件作者必须看的坑
收 modifier: Modifier = Modifier 时,调用方传进来的 modifier 在最外层,它定的尺寸赢。
kotlin
// ❌ 组件内部的 height(172.dp) 在内层,掰不过调用方的外层约束
@Composable
fun BannerCard(modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth().height(172.dp).clip(shape).background(bg))
}
BannerCard(modifier = Modifier.height(100.dp)) // 渲染 100dp,组件的 172dp 丢了
// ✅ A:组件默认值在前、调用方在后,可覆盖
Modifier.fillMaxWidth().height(172.dp).clip(shape).background(bg).then(modifier)
// ✅ B:只给下限,不封顶
modifier.fillMaxWidth().defaultMinSize(minHeight = 172.dp).clip(shape).background(bg)规则:收 modifier 参数的组件,别在调用方 modifier 之后链死 .height() / .width() / .size()。
4.2 IntrinsicSize 的代价
让兄弟对齐同一尺寸,典型场景是「一条竖线和最高的兄弟一样高」:
kotlin
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Text("很长的标签", Modifier.padding(end = 8.dp))
Box(Modifier.fillMaxHeight().width(1.dp).background(MaterialTheme.colorScheme.outlineVariant))
Text("值")
}IntrinsicSize 是真的贵
每个孩子多测量一趟(先问 intrinsic 尺寸,再正式测量),嵌套越深叠加越多,在 LazyColumn 的 item 里用就会掉帧;而且 Lazy* 和 SubcomposeLayout 不支持 intrinsics,被请求时直接抛 IllegalStateException: Asking for intrinsic measurements of SubcomposeLayout is not supported。列表项里不用,静态区块里才用。
五、自定义 Layout 与 SubcomposeLayout
默认答案:不值得。 先把容器用法榨干:两端顶死用 Arrangement.SpaceBetween,固定块 + 自适应块用 weight(1f),叠层用 Box,微调用 Modifier.offset。
5.1 自定义 Layout 什么时候写
只有一种情况值得:排布规则取决于孩子量完之后的尺寸,Arrangement 表达不出来——按内容宽度折行的标签流、环形排布、按内容宽度决定列数又不能懒加载的双列瀑布。
kotlin
@Composable
fun WrapRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val maxWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else Int.MAX_VALUE
var x = 0; var y = 0; var rowHeight = 0
val placed = measurables.map { m ->
val p = m.measure(constraints.copy(minWidth = 0, minHeight = 0))
if (x + p.width > maxWidth) { x = 0; y += rowHeight; rowHeight = 0 }
val at = x to y
x += p.width; rowHeight = maxOf(rowHeight, p.height)
p to at
}
layout(maxWidth, y + rowHeight) {
placed.forEach { (p, at) -> p.placeRelative(at.first, at.second) }
}
}
}自己负责的事:约束兜底(父给无界宽)、RTL(用 placeRelative 不用 place)、别再引人 intrinsics。
5.2 SubcomposeLayout 贵在哪
它允许你在测量阶段才决定组合什么内容(「按可用宽度决定这行放 2 个还是 3 个卡片」)。代价:
| 代价 | 说明 |
|---|---|
| 多一趟 composition | 测量时触发 subcomposition,每项多走一次「组合 → 测量 → 布局」 |
| 不支持 intrinsics | 被 IntrinsicSize 或别处的 intrinsic 请求碰到就抛异常 |
| 不能当 Lazy 的 item | Lazy 本身就是 SubcomposeLayout,嵌一层等于每项多一次组合 |
| slot 变化丢状态 | slot 数量 / 顺序一变,滚动位置、动画进度可能被丢弃重建 |
插件页面里几乎用不上 SubcomposeLayout
先问自己:能不能用一个等宽 weight 的 Row 加固定列数糊过去?能就别写。非用不可时:只放在页面最外层、别进列表、别和 IntrinsicSize 出现在同一个布局里;每个 subcompose(slotId) 用数据本身的 key 做稳定 id,别用 index,否则状态会乱丢。
六、Modifier.Node 与 @Composable 工厂修饰符
@Composable 工厂(含旧的 composed { })每次重组都会重跑并新建一个组合 scope;Modifier.Node 的节点挂在 layout 树上、脱离组合,只在 element 不相等时更新。
kotlin
// ❌ 调用点每次重组都重跑:新 scope + 新 lambda,equals 不相等 → 下游节点重挂 / 重绘
@Composable
fun Modifier.shimmer(): Modifier = composed {
val transition = rememberInfiniteTransition()
this.then(Modifier.background(/* … */))
}kotlin
// ✅ element 是数据类,equals 只比参数;节点脱离组合,update 只改变化的部分
private data class PressScaleElement(val pressed: Boolean) :
ModifierNodeElement<PressScaleNode>() {
override fun create() = PressScaleNode(pressed)
override fun update(node: PressScaleNode) { node.pressed = pressed }
}
private class PressScaleNode(var pressed: Boolean) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() { drawContent() /* 按 pressed 画高亮 / 缩放 */ }
}
fun Modifier.pressScale(pressed: Boolean) = this then PressScaleElement(pressed)| 场景 | 写法 |
|---|---|
| 静态拼接,无条件无状态 | 普通 fun Modifier.foo() = this.then(...) |
| 条件拼接 | .then(if (selected) A else Modifier),别生成两条链再拼 |
需要 remember / 动画 / 读 CompositionLocal,且调用点很少 | composed { } 可以用,别放进列表 |
| 列表项里、每帧变、手势 / 绘制热路径 | Modifier.Node |
Modifier 链本身是廉价对象
每次重组新建一串不可变 Modifier 不会卡,真正的开销在 composed / @Composable 工厂把组合卷进来。所以反模式不是「在 composition 里写链」,而是在列表项里用 @Composable 工厂修饰符,以及组件内部丢掉调用方传进来的 modifier——组件签名一律写 modifier: Modifier = Modifier,内部从它接着链。
七、8dp 网格速查表
尺寸只从 4 的倍数里取,优先 8 的倍数;4dp 只用于图标 / 文字微调。
| 位置 | 值 | 说明 |
|---|---|---|
| 页面左右内边距 | 16dp | 全页统一基准 |
| 页面顶 / 底第一块与边缘 | 16dp | TopTitleScaffold 已处理标题栏,别再补 statusBarsPadding() |
| 卡片内边距 | 16dp | 排在 background / clickable 之后 |
| 卡片之间 | 12dp | 用 Arrangement.spacedBy(12.dp) |
| 区块之间 | 24dp | 不同分组之间,视觉上要断开 |
| 列表项之间 | 0 | 靠 HorizontalDivider 或 ListItem 自身 |
| 列表项垂直内边距 | 12–16dp | 用组件库 ListItem,别自己写 |
| 图标与它的文字 | 8dp | 图标 20dp 或 24dp |
| 一行里并排的按钮 | 8dp | 三个以上动作才考虑更小的 size |
| 底部最后一项与屏幕底 | 24dp | 用 contentPadding 留,别用外层 padding |
| 最小触控目标 | 48dp | 用 sizeIn 撑命中区,不放大视觉块 |
字阶、圆角、标题栏间距不归你管:字号走 MaterialTheme.typography.*,颜色走 MaterialTheme.colorScheme.*,圆角走 AppShapes,标题栏由 TopTitleScaffold 负责。这一页只管间距和 Modifier 顺序。
八、实战坑
8.1 verticalScroll 里放 LazyColumn 直接崩
IllegalStateException: Vertically scrollable component was measured with an infinity
maximum height constraints, which is disallowed. One of the common reasons is nesting
layouts like LazyColumn and Column(Modifier.verticalScroll()).verticalScroll 给子项的约束高度是无穷(它要滑的就是全部内容的高),而 LazyColumn 需要有限高度才知道视口里该显示哪几项,两者是死结。
kotlin
// ✅ 默认方案:整页只有一个 LazyColumn,头部 / 脚部都当 item
LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)) {
item { HeaderSection() }
items(list, key = { it.id }) { ListItem() } // 参数见组件库文档
item { if (loading) LoadingIndicator() }
item { if (!hasMore) EmptyHint(text = "没有更多了") }
}
// ✅ 内容少且固定:Column + verticalScroll,不要 Lazy
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { }
// ✅ 横向嵌在纵向里没问题,轴不同不会死锁
LazyColumn { item { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { /* PillTab */ } } }别用「给它一个固定高度」绕过
.height(400.dp) 确实不崩了,但滚动区变成一条固定高度的洞:头部跟着一起滑、底部内容被切、外面还多一层滚动。这是 bug 换皮。
8.2 同方向嵌套滚动
| 嵌套 | 能不能用 |
|---|---|
| 纵向里嵌横向 | ✅ 轴不同,可以用 |
| 纵向里嵌纵向 | ❌ 内层吃掉手势,外层再也到不了底 |
| 懒列表里嵌同向懒列表 | ❌ 手势 + 性能双输 |
真要「整页滑 + 局部滑」就别嵌套:换成单个 LazyColumn,把局部内容展开成 item。
8.3 padding 不是 margin
padding 在 background / clickable 之前就是外边距(背景和命中区都不含它),之后就是内边距(背景和命中区含它,只有内容内缩)。看到「背景小一圈」「波纹缩在中间」「边上一圈点不动」,先查 padding 和 background / clickable 谁在前。
8.4 给父级加 padding,还是给每个孩子加
| 做法 | 结果 | 用途 |
|---|---|---|
父加 padding | 只有最外圈有间距,孩子之间没有 | 整体外边距(页面左右 16dp) |
每个孩子加 padding | 相邻间距相加(8+8=16),每个孩子的背景 / 命中区都缩小 | 几乎不用 |
父用 Arrangement.spacedBy(8.dp) | 孩子之间固定 8dp,两端不留 | 组内间距首选 |
孩子的 padding 写在 background 之后 | 孩子自己的内边距 | 卡片内边距 |
整体外边距给父级 padding,组内间距给 Arrangement.spacedBy,卡片内边距给卡片自己——三件事三个工具。
8.5 滚动容器的 padding:外侧会被裁
kotlin
// ❌ 外层 padding:内容在离屏幕 16dp 的地方就被裁,最后一项贴着裁切线
LazyColumn(Modifier.padding(16.dp)) { }
// ✅ contentPadding:视口仍是全屏,16dp 变成可滚动的内容内边距
LazyColumn(contentPadding = PaddingValues(16.dp)) { }Column(verticalScroll) 没有 contentPadding,顺序自己排:Modifier.fillMaxSize().verticalScroll(state).padding(16.dp)——滚动在内、padding 在内层,四周留白跟着内容一起滚;反过来写,16dp 边框固定不动,内容贴着它裁。
九、落笔前的自查
- [ ] 可点元素的
clickable在padding之前(外层),命中区含内边距 - [ ] 圆角处
clip(shape)在background之前、也在子内容之前;border与background同 shape、中间不夹padding - [ ] 同一行里没有同时出现
fillMaxWidth()和weight(1f);IntrinsicSize没出现在Lazy*的 item 里 - [ ] 页面里只有一个纵向滚动容器,滚动列表用
contentPadding而不是外层Modifier.padding - [ ] 间距只取 4 的倍数、优先 8 的倍数;组内间距用
Arrangement.spacedBy - [ ] 组件有
modifier: Modifier = Modifier,内部从它接着链、末尾.then(modifier) - [ ] 列表项里没有
@Composable工厂修饰符 /composed { }
列表、滚动与自动加载更多的完整写法见列表、滚动与自动加载更多。