16、自定义布局:Layout composable、测量与放置子元素
自定义布局,说白了就是让你自己决定子组件怎么摆、怎么量、怎么放。Compose 默认提供的 Column、Row、Box 虽然好用,但遇到特殊需求——比如流式布局、环形菜单、瀑布流——你就得自己动手了。
我刚开始接触 Compose 时,觉得自定义布局很神秘。后来在项目中做过一个标签云组件,才真正搞懂了 Layout 的测量与放置流程。嗯,今天咱们就把这块彻底讲透。
16.1 为什么需要自定义布局?
先问个问题:什么时候你会觉得 Column 不够用?
- 子元素大小不一,你想让它们自动换行(类似 Flexbox 的 wrap)
- 子元素需要按特定角度排列(比如圆形菜单)
- 子元素需要根据父容器尺寸动态调整位置
这些场景下,系统提供的布局组件就捉襟见肘了。你需要的是——Layout composable,它是 Compose 中最底层的布局 API。
核心思想:自定义布局 = 测量每个子元素 → 根据测量结果决定位置 → 放置子元素。
16.2 Layout composable 的基本用法
Layout 是一个函数,签名大概长这样:
@Composable
fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
关键在第三个参数 measurePolicy。它是一个接口,你需要实现它的 measure 方法。这个方法会收到一个 Measurable 列表和一个 Constraints,然后返回 MeasureResult。
我习惯把自定义布局拆成三步:
- 测量子元素:调用
measurable.measure(constraints)得到每个子元素的Placeable - 计算布局尺寸:根据所有子元素的尺寸,决定整个布局的宽高
- 放置子元素:调用
placeable.place(x, y)把每个子元素放到指定位置
小提示:测量时传入的 constraints 可以自己调整。比如你想让子元素最多占父容器的一半,可以用 constraints.copy(maxWidth = constraints.maxWidth / 2)。
16.3 实战:写一个简单的流式布局
咱们写一个 FlowLayout,让子元素从左到右排列,排不下就换行。这在做标签云、搜索历史时非常实用。
@Composable
fun FlowLayout(
modifier: Modifier = Modifier,
horizontalSpacing: Dp = 8.dp,
verticalSpacing: Dp = 8.dp,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1. 测量所有子元素
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// 2. 计算布局尺寸
var currentX = 0
var currentY = 0
var rowMaxHeight = 0
var totalWidth = 0
var totalHeight = 0
val positions = mutableListOf<Pair<Int, Int>>()
placeables.forEach { placeable ->
// 如果当前行放不下,换行
if (currentX + placeable.width > constraints.maxWidth) {
currentX = 0
currentY += rowMaxHeight + verticalSpacing.roundToPx()
rowMaxHeight = 0
}
positions.add(Pair(currentX, currentY))
rowMaxHeight = maxOf(rowMaxHeight, placeable.height)
currentX += placeable.width + horizontalSpacing.roundToPx()
totalWidth = maxOf(totalWidth, currentX)
}
totalHeight = currentY + rowMaxHeight
// 3. 放置子元素
layout(totalWidth, totalHeight) {
placeables.forEachIndexed { index, placeable ->
val (x, y) = positions[index]
placeable.place(x, y)
}
}
}
}
这段代码我写过很多次。第一次写的时候忘了处理 verticalSpacing 的像素转换,结果换行间距忽大忽小。后来养成了习惯:所有 Dp 值在测量阶段都要转成 px。
16.4 测量与放置的细节
你可能会问:为什么测量和放置要分开?
因为 Compose 的布局是单次遍历。你不能先放一个子元素,量一下剩余空间,再决定下一个怎么放。所有子元素必须同时测量完,然后统一放置。这就是所谓的「声明式」——你描述的是最终状态,而不是过程。
注意:不要在 measure 方法里调用 place。测量阶段只负责计算尺寸和位置,放置阶段才真正把子元素画出来。混在一起会导致布局不稳定,甚至崩溃。
另外,Constraints 对象包含 minWidth、maxWidth、minHeight、maxHeight。如果你不关心最小值,可以用 constraints.copy(minWidth = 0) 来放宽限制。
16.5 自定义布局的常见模式
我在项目中总结了几种常用模式:
| 模式 | 适用场景 | 关键点 |
|---|---|---|
| 等分排列 | 子元素平分父容器宽度 | 每个子元素宽度 = constraints.maxWidth / 子元素数量 |
| 流式排列 | 标签云、搜索历史 | 记录当前行位置,超宽换行 |
| 重叠排列 | 头像堆叠、卡片层叠 | 所有子元素放在同一个 (x, y) |
| 环形排列 | 圆形菜单、仪表盘 | 用三角函数计算每个子元素的角度位置 |
举个例子,环形排列的核心代码:
val angleStep = 2 * PI / placeables.size
placeables.forEachIndexed { index, placeable ->
val angle = angleStep * index
val x = centerX + (radius * cos(angle)).toInt() - placeable.width / 2
val y = centerY + (radius * sin(angle)).toInt() - placeable.height / 2
placeable.place(x, y)
}
我曾经在做一个智能家居 App 时,用这个模式实现了设备状态环形图。每个设备图标围绕中心旋转,点击后展开详情。效果很酷,但调试时发现角度计算有偏差——因为忘了减去子元素自身宽高的一半。嗯,这种细节最容易翻车。
16.6 性能注意事项
自定义布局如果写得不好,很容易造成性能问题。我踩过几个坑:
- 不要重复测量:每个子元素只测量一次,把结果缓存起来
- 避免在测量中做耗时计算:比如字符串解析、网络请求,这些应该提前算好
- 合理使用 constraints:如果子元素不需要撑满父容器,记得设置
maxWidth上限
我的习惯:在自定义布局的 measure 方法里加一个 trace("FlowLayout.measure"),用 Systrace 查看每次布局的耗时。如果超过 1ms,就要考虑优化了。
16.7 知识体系总览
下面这张图帮你理清自定义布局的核心流程:
16.8 写在最后
自定义布局是 Compose 中比较进阶的内容,但掌握了它,你就能做出任何你想要的界面效果。我个人觉得,理解「测量与放置分离」这个思想,比记住 API 更重要。
如果你在项目中遇到 Column 和 Row 搞不定的布局,不妨试试自己写一个 Layout。刚开始可能会觉得别扭,但多写几次就会发现——其实就那么回事。
一句话总结:Layout 给你的是「测量权」和「放置权」,怎么用,全看你的想象力。