13、自定义布局:Layout 与 MeasurePolicy,自定义测量与摆放,IntrinsicSize 与固有尺寸
说实话,Compose 的布局系统,是我从传统 View 体系转过来时最头疼的一块。以前写 XML,LinearLayout、RelativeLayout 用得很顺手,但到了 Compose 里,你会发现「一切皆可自定义」。这既是好事,也是挑战。
今天咱们就聊聊自定义布局。说白了,就是让你自己决定「每个子组件该多大、该放哪」。Compose 给了你两把钥匙:Layout 和 MeasurePolicy。掌握了它们,你就能写出像 FlowRow、StaggeredGrid 那样的高级布局。
13.1 Layout 与 MeasurePolicy:布局的底层 API
先看一个最简单的例子。假设我想实现一个「水平排列,但每个子项之间留 8dp 间距」的布局。用 Row 当然可以,但为了演示,咱们手写一个:
@Composable
fun MyHorizontalLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1. 测量所有子组件
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// 2. 计算总宽度
val totalWidth = placeables.sumOf { it.width } +
(placeables.size - 1) * 8.dp.roundToPx()
// 3. 确定布局尺寸
val height = placeables.maxOfOrNull { it.height } ?: 0
layout(totalWidth, height) {
var x = 0
placeables.forEach { placeable ->
placeable.placeRelative(x, 0)
x += placeable.width + 8.dp.roundToPx()
}
}
}
}
这段代码的核心,就是 Layout 的最后一个参数——一个 lambda。这个 lambda 的类型就是 MeasurePolicy。它接收两个参数:measurables(所有子组件的测量句柄)和 constraints(父布局给你的约束)。
我个人习惯把测量和摆放分开写,这样逻辑更清晰。测量阶段,你调用 measurable.measure(constraints) 得到每个子项的尺寸;摆放阶段,你调用 placeable.placeRelative(x, y) 把它们放到指定位置。
measure(),否则子组件不会被渲染。而且每个 measurable 只能测量一次,不能重复测量。
13.2 自定义测量:打破约束的边界
有时候,父布局给你的约束太死板了。比如父布局说「你最大宽度是 200px」,但你想让某个子项占满剩余空间。这时候就需要自定义测量。
我记得在做一个「标签云」组件时,遇到了一个坑:每个标签的宽度应该由内容决定,但父布局给了固定宽度约束。怎么办?
Layout(content = content) { measurables, constraints ->
val placeables = measurables.map { measurable ->
// 忽略父布局的 maxWidth,让子项自由测量
val looseConstraints = constraints.copy(
maxWidth = Constraints.Infinity
)
measurable.measure(looseConstraints)
}
// ... 后续摆放
}
这里我用 constraints.copy(maxWidth = Constraints.Infinity) 创建了一个新的约束,告诉子项「你想多宽就多宽」。但要注意,最终布局的尺寸还是要受父布局约束的,你不能超出父布局给你的范围。
layout(width, height) 里的宽高必须满足父布局的约束,否则 Compose 会强制裁剪。
13.3 自定义摆放:不只是简单的坐标
摆放阶段,除了 placeRelative,还有 place 和 placeWithLayer。它们有什么区别?
| 方法 | 说明 | 适用场景 |
|---|---|---|
placeRelative |
考虑布局方向(LTR/RTL) | 大多数场景,支持国际化 |
place |
不考虑布局方向,始终从左到右 | 自定义绘制、动画 |
placeWithLayer |
支持设置透明度、缩放等 | 需要动画效果时 |
举个例子,我想实现一个「瀑布流」布局,子项从左到右排列,但高度不同。摆放时就需要计算每一列的当前 Y 坐标:
layout(totalWidth, totalHeight) {
val columnHeights = IntArray(columnCount) { 0 }
placeables.forEachIndexed { index, placeable ->
val column = index % columnCount
val x = column * columnWidth
val y = columnHeights[column]
placeable.placeRelative(x, y)
columnHeights[column] += placeable.height + spacing
}
}
你想想看,这种逻辑用传统 View 的 onLayout 写起来多麻烦?但在 Compose 里,就是一个循环加一个数组的事。
13.4 IntrinsicSize:让布局更智能
说到固有尺寸,我得先讲一个真实案例。有一次我做了一个「卡片列表」,每个卡片左边是图片,右边是文字。文字区域用了 Column,里面有两个 Text。结果发现,当文字内容很少时,卡片高度被撑得很大,因为 Column 默认会占满可用高度。
解决方案就是 IntrinsicSize。它告诉布局:「请根据子组件的固有尺寸来决定我的大小,而不是强行填满。」
Column(
modifier = Modifier
.height(IntrinsicSize.Min) // 高度取子组件的最小固有高度
) {
Text("标题", fontSize = 18.sp)
Text("描述内容", fontSize = 14.sp)
}
这里 IntrinsicSize.Min 表示「取所有子组件固有高度的最小值」。还有 IntrinsicSize.Max,表示取最大值。你可能会问:「固有尺寸是什么?」
说白了,就是组件在不考虑父布局约束时,自己觉得「最合适」的尺寸。比如一个 Text,它的固有宽度就是文字内容的宽度,固有高度就是文字行高。
IntrinsicSize,需要重写 MeasurePolicy 的 minIntrinsicWidth、maxIntrinsicWidth 等方法。不过大多数情况下,直接用 Compose 提供的布局(如 Column、Row)加上 IntrinsicSize 就够了。
13.5 实战:写一个「流式布局」FlowLayout
理论说完了,咱们动手写一个真正有用的自定义布局——FlowLayout。它类似于 CSS 里的 flex-wrap,子项超出宽度自动换行。
@Composable
fun FlowLayout(
modifier: Modifier = Modifier,
horizontalSpacing: Dp = 8.dp,
verticalSpacing: Dp = 8.dp,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val spacingX = horizontalSpacing.roundToPx()
val spacingY = verticalSpacing.roundToPx()
val rows = mutableListOf<MutableList<Placeable>>()
val rowWidths = mutableListOf<Int>()
var currentRow = mutableListOf<Placeable>()
var currentWidth = 0
// 第一遍:分行
placeables.forEach { placeable ->
if (currentWidth + placeable.width > constraints.maxWidth) {
rows.add(currentRow)
rowWidths.add(currentWidth - spacingX)
currentRow = mutableListOf()
currentWidth = 0
}
currentRow.add(placeable)
currentWidth += placeable.width + spacingX
}
if (currentRow.isNotEmpty()) {
rows.add(currentRow)
rowWidths.add(currentWidth - spacingX)
}
// 计算总高度
val totalHeight = rows.sumOf { row ->
row.maxOfOrNull { it.height } ?: 0
} + (rows.size - 1) * spacingY
val totalWidth = constraints.maxWidth
layout(totalWidth, totalHeight) {
var y = 0
rows.forEach { row ->
val rowHeight = row.maxOfOrNull { it.height } ?: 0
var x = 0
row.forEach { placeable ->
placeable.placeRelative(x, y)
x += placeable.width + spacingX
}
y += rowHeight + spacingY
}
}
}
}
这段代码我分了两个阶段:先分行,再摆放。分行时要注意,每个子项宽度不能超过父布局最大宽度,否则会死循环。嗯,这里其实应该加一个判断,如果某个子项宽度本身就超过父布局,那就单独占一行。
FlowLayout(
horizontalSpacing = 12.dp,
verticalSpacing = 8.dp
) {
repeat(20) {
Text(
text = "标签 $it",
modifier = Modifier
.background(Color.LightGray)
.padding(8.dp)
)
}
}
13.6 知识体系总览
下面这张图,是我梳理的自定义布局核心逻辑。你可以看到,所有自定义布局都从 Layout 开始,经过测量和摆放两个阶段,最终渲染到屏幕上。
从图中你可以看到,整个流程是线性的:Layout 调用 MeasurePolicy,先测量所有子组件,再摆放它们。而 IntrinsicSize 就像是一个「参谋」,在测量阶段提供参考尺寸,让布局更智能。
13.7 避坑指南
最后,分享几个我实际开发中遇到的坑:
- 不要重复测量:每个
measurable只能调用一次measure()。如果你需要多次测量,请先复制一份约束。 - 注意约束的边界:
constraints.maxWidth可能是Constraints.Infinity,这时候你的布局应该能自适应。 - IntrinsicSize 不是万能的:它只适用于某些场景,比如文字内容不确定时。如果你的布局逻辑很复杂,建议直接手写测量。
- 性能问题:自定义布局中不要做耗时操作,比如网络请求或复杂计算。测量和摆放是在 UI 线程执行的。
我曾经在自定义布局里写了一个排序算法,结果列表滑动时卡成 PPT。后来把排序移到了 ViewModel 里,用 derivedStateOf 触发重组,问题就解决了。
好了,自定义布局的核心内容就这些。说白了,就是「测量 + 摆放」两个步骤,加上 IntrinsicSize 这个辅助工具。你可以在自己的项目里试着写一个 FlowLayout 或者 StaggeredGrid,相信我,写完之后你对 Compose 布局的理解会上一个台阶。