Skip to content

布局与 Modifier ​

Compose 里的尺寸、间距、背景范围、命中区,全由 Modifier 链的顺序决定。顺序写错,代码照样编译、照样跑,界面就是差一点:背景比卡片小一圈、水波纹从圆角外漏出来、16dp 内边距点不动。

骨架看 7 UI 设计和业务开发,组件从 UI 组件库 挑,这一页只管链。下文用 bg 代指 MaterialTheme.colorScheme.surfaceContainerLow、用 shape 代指从 AppShapes 取出来的圆角 token(不自己写 RoundedCornerShape)。

一、链的顺序决定一切 ​

三条规则:

  1. 靠前的在外层,靠后的在内层。Modifier.a().b() 读作「a 包住 b」。
  2. 外层先定尺寸,内层只能缩小不能长大。先 size(100.dp) 再 padding(16.dp),padding 撑不出去。
  3. 每个修饰符只影响自己和内层。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×68background 在内,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 的 itemLazy 本身就是 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全页统一基准
页面顶 / 底第一块与边缘16dpTopTitleScaffold 已处理标题栏,别再补 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 { }

列表、滚动与自动加载更多的完整写法见列表、滚动与自动加载更多。