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

我习惯把自定义布局拆成三步:

  1. 测量子元素:调用 measurable.measure(constraints) 得到每个子元素的 Placeable
  2. 计算布局尺寸:根据所有子元素的尺寸,决定整个布局的宽高
  3. 放置子元素:调用 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 对象包含 minWidthmaxWidthminHeightmaxHeight。如果你不关心最小值,可以用 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 知识体系总览

下面这张图帮你理清自定义布局的核心流程:

自定义布局核心流程 1. 测量子元素 measurable.measure() 2. 计算布局尺寸 遍历 placeables 算宽高 3. 放置子元素 placeable.place() 返回 MeasureResult 包含布局宽高 + 子元素位置 ⚠️ 注意事项 • 测量阶段不要调用 place() • 每个子元素只测量一次,避免重复 • Dp 值记得转 px • 合理使用 constraints 限制尺寸

16.8 写在最后

自定义布局是 Compose 中比较进阶的内容,但掌握了它,你就能做出任何你想要的界面效果。我个人觉得,理解「测量与放置分离」这个思想,比记住 API 更重要。

如果你在项目中遇到 Column 和 Row 搞不定的布局,不妨试试自己写一个 Layout。刚开始可能会觉得别扭,但多写几次就会发现——其实就那么回事。

一句话总结:Layout 给你的是「测量权」和「放置权」,怎么用,全看你的想象力。


公众号:蓝海资料掘金营,微信deep3321