13、自定义布局:Layout 与 MeasurePolicy,自定义测量与摆放,IntrinsicSize 与固有尺寸

说实话,Compose 的布局系统,是我从传统 View 体系转过来时最头疼的一块。以前写 XML,LinearLayout、RelativeLayout 用得很顺手,但到了 Compose 里,你会发现「一切皆可自定义」。这既是好事,也是挑战。

今天咱们就聊聊自定义布局。说白了,就是让你自己决定「每个子组件该多大、该放哪」。Compose 给了你两把钥匙:LayoutMeasurePolicy。掌握了它们,你就能写出像 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,还有 placeplaceWithLayer。它们有什么区别?

方法 说明 适用场景
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,需要重写 MeasurePolicyminIntrinsicWidthmaxIntrinsicWidth 等方法。不过大多数情况下,直接用 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 接口 测量阶段 measurable.measure(constraints) 摆放阶段 placeable.placeRelative(x, y) layout(width, height) 输出尺寸 子组件渲染到屏幕 IntrinsicSize 可介入测量阶段,提供固有尺寸参考

从图中你可以看到,整个流程是线性的:Layout 调用 MeasurePolicy,先测量所有子组件,再摆放它们。而 IntrinsicSize 就像是一个「参谋」,在测量阶段提供参考尺寸,让布局更智能。

13.7 避坑指南

最后,分享几个我实际开发中遇到的坑:

  • 不要重复测量:每个 measurable 只能调用一次 measure()。如果你需要多次测量,请先复制一份约束。
  • 注意约束的边界constraints.maxWidth 可能是 Constraints.Infinity,这时候你的布局应该能自适应。
  • IntrinsicSize 不是万能的:它只适用于某些场景,比如文字内容不确定时。如果你的布局逻辑很复杂,建议直接手写测量。
  • 性能问题:自定义布局中不要做耗时操作,比如网络请求或复杂计算。测量和摆放是在 UI 线程执行的。

我曾经在自定义布局里写了一个排序算法,结果列表滑动时卡成 PPT。后来把排序移到了 ViewModel 里,用 derivedStateOf 触发重组,问题就解决了。

好了,自定义布局的核心内容就这些。说白了,就是「测量 + 摆放」两个步骤,加上 IntrinsicSize 这个辅助工具。你可以在自己的项目里试着写一个 FlowLayout 或者 StaggeredGrid,相信我,写完之后你对 Compose 布局的理解会上一个台阶。

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