上一篇 下一篇 回到顶部 目录 返回首页
目录

Jetpack Compose 自定义布局进阶:从原理到实战

发表于
更新于
21 35.3~45.4 分钟 15876

前言

当 Compose 内置的 Column、Row、Box 和懒布局无法满足需求时,你需要深入到布局系统的底层,亲手控制测量和放置过程。本文将深入讲解 Compose 自定义布局的完整体系:从 layout 修饰符到 Layout 可组合函数,从 SubcomposeLayoutModifier.Node 级自定义修饰符,以及内部测量、对齐线等高级精确布局工具。


第一部分:布局原理

1.1 布局三步骤

Compose 中每个 UI 节点遵循严格的三步布局流程

  1. 测量子元素:在给定约束下请求子元素的期望尺寸

  2. 决定自身尺寸:计算并报告自身的最终 widthheight

  3. 放置子元素:使用 (x, y) 坐标相对父元素定位子元素

父元素 ──(约束)──▶ 子元素测量
子元素 ──(尺寸)──▶ 父元素决定大小
父元素 ──(坐标)──▶ 放置子元素

1.2 单次测量约束

Compose 不允许多次测量。每个子元素在一次布局传递中只能被测量一次。这是 Compose 布局性能的核心保障。

编译时作用域(MeasureScopePlacementScope)强制执行这一约束:测量只能在测量阶段发生,放置只能在放置阶段发生。

1.3 约束类型

约束类型

说明

有界(Bounded)

有明确定义的最小/最大边界

无界(Unbounded)

最大边界为无穷大

精确(Exact)

最小和最大边界相等(固定尺寸)

混合

宽度固定但高度有界等组合

1.4 布局方向与放置

  • placeRelative(x, y):尊重当前布局方向(RTL 会翻转 x 轴)

  • place(x, y):绝对放置,忽略布局方向

通过修改 LocalLayoutDirection CompositionLocal 可以改变布局方向。


第二部分:自定义布局的两种方式

2.1 layout 修饰符

当你只需要修改单个调用者可组合函数的测量和放置方式时使用。

实战案例 — 首行基线对齐

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) =
    layout { measurable, constraints ->
        // 1. 测量可组合函数
        val placeable = measurable.measure(constraints)

        // 2. 获取首行基线位置
        val firstBaseline = placeable[FirstBaseline]

        // 3. 计算新的 y 位置
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline

        layout(placeable.width, placeable.height + placeableY) {
            // 4. 放置可组合函数(向下偏移)
            placeable.placeRelative(0, placeableY)
        }
    }

使用方式:

Text(
    text = "Hello Compose",
    modifier = Modifier.firstBaselineToTop(32.dp)
)

关键点

  • measurable.measure(constraints) 触发子元素测量,返回 Placeable

  • placeable[FirstBaseline] 读取对齐线值(必须在测量之后)

  • layout(width, height) { } 设置自身尺寸并提供放置作用域

  • placeable.placeRelative(x, y) 放置子元素

2.2 Layout 可组合函数

当你需要测量和放置多个子元素时使用。所有内置布局(Column、Row 等)都是用它构建的。

实战案例 — 自定义垂直堆叠布局

@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 1. 测量所有子元素
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        // 2. 计算布局总高度
        var totalHeight = 0
        placeables.forEach { totalHeight += it.height }

        // 确保不超过父元素的最大高度约束
        val layoutWidth = constraints.maxWidth
        val layoutHeight = totalHeight.coerceAtMost(constraints.maxHeight)

        // 3. 设置父元素尺寸并放置子元素
        layout(layoutWidth, layoutHeight) {
            var yPosition = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                yPosition += placeable.height
            }
        }
    }
}

使用方式:

MyBasicColumn(modifier = Modifier.padding(16.dp)) {
    Text("First line")
    Text("Second line")
    Text("Third line")
}

第三部分:实战自定义布局

3.1 瀑布流布局(Staggered Grid)

交错列布局,每列高度不同,项总是填充到最短列:

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    columns: Int = 2,
    gap: Dp = 8.dp,
    content: @Composable () -> Unit
) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
        val gapPx = gap.roundToPx()
        val columnWidth = (constraints.maxWidth - gapPx * (columns - 1)) / columns

        // 每列的当前高度
        val columnHeights = IntArray(columns) { 0 }

        // 每个子元素的测量结果和放置位置
        val placeableData = mutableListOf<Triple<Placeable, Int, Int>>()

        measurables.forEach { measurable ->
            // 找到最短列
            val shortestColumn = columnHeights.indices.minByOrNull { columnHeights[it] }!!

            // 用列宽约束测量子元素
            val columnConstraints = Constraints.fixedWidth(columnWidth)
            val placeable = measurable.measure(columnConstraints)

            // 记录放置位置
            val x = shortestColumn * (columnWidth + gapPx)
            val y = columnHeights[shortestColumn]

            placeableData.add(Triple(placeable, x, y))

            // 更新列高度
            columnHeights[shortestColumn] = y + placeable.height + gapPx
        }

        // 布局总尺寸
        val totalWidth = constraints.maxWidth
        val totalHeight = columnHeights.maxOrNull()?.coerceAtMost(constraints.maxHeight) ?: 0

        layout(totalWidth, totalHeight) {
            placeableData.forEach { (placeable, x, y) ->
                placeable.placeRelative(x, y)
            }
        }
    }
}

3.2 均匀分布圆环布局

将子元素排列成圆形:

@Composable
fun CircularLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
        // 用最大可用空间测量所有子元素
        val placeables = measurables.map { it.measure(constraints) }

        val count = placeables.size
        if (count == 0) {
            layout(0, 0) { return@layout }
        }

        // 找到最大子元素尺寸
        val maxChildSize = placeables.maxOf { max(it.width, it.height) }

        // 布局尺寸:足够容纳所有子元素
        val layoutSize = maxChildSize + (constraints.maxWidth - maxChildSize).coerceAtMost(
            constraints.maxHeight - maxChildSize
        )
        val radius = (layoutSize - maxChildSize) / 2

        layout(layoutSize, layoutSize) {
            placeables.forEachIndexed { index, placeable ->
                val angle = 2 * PI * index / count
                val x = (radius + maxChildSize / 2 + radius * cos(angle) - placeable.width / 2).toInt()
                val y = (radius + maxChildSize / 2 + radius * sin(angle) - placeable.height / 2).toInt()
                placeable.placeRelative(x, y)
            }
        }
    }
}

3.3 带约束感知的响应式流

根据可用空间自动切换单行/双行布局:

@Composable
fun ResponsiveFlow(
    modifier: Modifier = Modifier,
    breakpoint: Dp = 400.dp,
    content: @Composable () -> Unit
) {
    Layout(modifier = modifier, content = content) { measurables, constraints ->
        val breakpointPx = breakpoint.roundToPx()

        val placeables = measurables.map { measurable ->
            measurable.measure(constraints.copy(minWidth = 0))
        }

        val layoutWidth = constraints.maxWidth
        val isWide = layoutWidth >= breakpointPx

        if (isWide) {
            // 双行布局:分成两组
            val mid = (placeables.size + 1) / 2
            val row1 = placeables.take(mid)
            val row2 = placeables.drop(mid)

            val row1Height = row1.maxOfOrNull { it.height } ?: 0
            val row2Height = row2.maxOfOrNull { it.height } ?: 0

            layout(layoutWidth, row1Height + row2Height) {
                var x = 0
                row1.forEach { it.placeRelative(x, 0).also { x += it.width } }
                x = 0
                row2.forEach { it.placeRelative(x, row1Height).also { x += it.width } }
            }
        } else {
            // 单行布局
            val totalHeight = placeables.sumOf { it.height }
            layout(layoutWidth, totalHeight) {
                var y = 0
                placeables.forEach {
                    it.placeRelative(0, y)
                    y += it.height
                }
            }
        }
    }
}

第四部分:SubcomposeLayout

4.1 为什么需要 SubcomposeLayout

Layout 在测量前会组合所有内容。但某些场景需要根据测量结果有条件地组合内容,比如懒布局(只组合可见项)或需要先测量一部分再决定另一部分的布局。

SubcomposeLayout 允许你在测量阶段按需组合子元素:

@Composable
fun MySubcomposeLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    SubcomposeLayout(modifier = modifier, content = content) { constraints ->
        // 先组合和测量一部分
        val mainPlaceables = subcompose("main") {
            Text("Main content")
        }.map { it.measure(constraints) }

        // 根据测量结果决定组合其他内容
        val mainHeight = mainPlaceables.maxOf { it.height }

        val detailPlaceables = if (mainHeight > 200) {
            subcompose("detail") {
                Text("Detail for tall content")
            }.map { it.measure(constraints) }
        } else {
            emptyList()
        }

        val layoutWidth = constraints.maxWidth
        val layoutHeight = mainHeight + (detailPlaceables.maxOfOrNull { it.height } ?: 0)

        layout(layoutWidth, layoutHeight) {
            var y = 0
            mainPlaceables.forEach { it.placeRelative(0, y).also { y += it.height } }
            detailPlaceables.forEach { it.placeRelative(0, y).also { y += it.height } }
        }
    }
}

4.2 实际场景 — 条件渲染

根据空间大小决定渲染什么:

@Composable
fun AdaptiveCard(
    compactContent: @Composable () -> Unit,
    expandedContent: @Composable () -> Unit
) {
    SubcomposeLayout { constraints ->
        val compactPlaceables = subcompose("compact", compactContent)
            .map { it.measure(constraints) }

        val compactHeight = compactPlaceables.maxOf { it.height }

        val isEnoughSpace = compactHeight < 300

        val contentPlaceables = if (isEnoughSpace) {
            subcompose("expanded", expandedContent)
                .map { it.measure(constraints) }
        } else {
            compactPlaceables
        }

        val layoutWidth = constraints.maxWidth
        val layoutHeight = contentPlaceables.maxOf { it.height }

        layout(layoutWidth, layoutHeight) {
            contentPlaceables.forEach { it.placeRelative(0, 0) }
        }
    }
}

第五部分:高级精确布局工具

5.1 对齐线(Alignment Lines)深入

对齐线是可组合函数暴露给父元素的参考坐标,支持精确的跨子元素对齐。

定义自定义对齐线

// 合并策略:当多个子元素报告同一条线时,取最小值(视觉上最高)
val MaxChartValue = HorizontalAlignmentLine { old, new -> min(old, new) }

// 合并策略:取最大值(视觉上最低)
val MinChartValue = HorizontalAlignmentLine { old, new -> max(old, new) }

在自定义布局中暴露对齐线

Layout(
    content = { /* chart content */ },
    modifier = Modifier.drawBehind { /* draw chart */ }
) { measurables, constraints ->
    with(constraints) {
        layout(width, height, alignmentLines = mapOf(
            MaxChartValue to maxYBaseline.roundToInt(),
            MinChartValue to minYBaseline.roundToInt()
        )) {
            // 放置子元素
        }
    }
}

在父布局中消费对齐线

Layout(content = content) { measurables, constraints ->
    val placeable = measurables.first().measure(constraints)
    val baseline = placeable[FirstBaseline] ?: AlignmentLine.Unspecified

    layout(constraints.maxWidth, constraints.maxHeight) {
        placeable.placeRelative(0, targetPaddingY - baseline)
    }
}

关键注意事项:

  • 对齐线仅在测量后可用。测量前读取返回 AlignmentLine.Unspecified

  • 对齐线自动传播到直接和间接父元素

  • Compose 的 (0,0) 在左上角,视觉上更高的位置 = 更小的 y 值

5.2 内部测量(Intrinsic Measurements)深入

内部测量允许在正式测量前查询可组合函数的理想尺寸,使父元素能基于子元素内容做尺寸决策。

关键修饰符

  • Modifier.width(IntrinsicSize.Min) — 最小需要宽度

  • Modifier.width(IntrinsicSize.Max) — 最大合理宽度

  • Modifier.height(IntrinsicSize.Min) — 最小需要高度

  • Modifier.height(IntrinsicSize.Max) — 最大合理高度

工作原理

请求内部测量不会测量子元素两次。子元素在测量前被查询内部尺寸,父元素据此计算约束后再测量子元素。

经典案例 — Divider 匹配文字高度

@Composable
fun TwoTexts(text1: String, text2: String, modifier: Modifier = Modifier) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(text = text1, modifier = Modifier.weight(1f))
        VerticalDivider(modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(text = text2, modifier = Modifier.weight(1f))
    }
}

流程解析:

  1. Row 查询 minIntrinsicHeight

  2. Text 返回单行高度

  3. Divider 内在高度为 0

  4. Row 高度 = 最高 Text 的高度

  5. Divider 通过 fillMaxHeight() 展开匹配

自定义布局中重写内部测量

自定义 Layout 会自动计算近似的内部测量,但对于复杂布局可能不准确。重写 MeasurePolicy 中的内部测量方法:

Layout(
    content = content,
    measurePolicy = object : MeasurePolicy {
        override fun MeasureScope.measure(
            measurables: List<Measurable>,
            constraints: Constraints
        ): MeasureResult {
            // 正常测量逻辑
        }

        override fun IntrinsicMeasureScope.minIntrinsicHeight(
            measurables: List<IntrinsicMeasurable>,
            width: Int
        ): Int {
            // 返回自定义最小高度逻辑
        }

        override fun IntrinsicMeasureScope.maxIntrinsicWidth(
            measurables: List<IntrinsicMeasurable>,
            height: Int
        ): Int {
            // 返回自定义最大宽度逻辑
        }
    }
)

5.3 可见性追踪

onVisibilityChangedonLayoutRectChanged 用于追踪 UI 元素何时在屏幕上可见。

Box(
    modifier = Modifier.onVisibilityChanged(
        minDurationMs = 3000,       // 连续可见 3 秒后才触发
        minFractionVisible = 0.2f   // 至少 20% 可见
    ) { visible ->
        if (visible) viewModel.fetchData()  // 预加载数据
    }
)

修饰符顺序很重要.onVisibilityChanged 应放在 .padding() 等布局修饰符之前,确保追踪的是组件的完整边界。


第六部分:自定义修饰符进阶(Modifier.Node)

6.1 三种自定义修饰符方式

方式一:链式组合(推荐大多数场景)

fun Modifier.myBackground(color: Color) =
    padding(16.dp)
        .clip(RoundedCornerShape(8.dp))
        .background(color)

方式二:Composable 工厂(需要状态和动画时)

@Composable
fun Modifier.fade(enabled: Boolean): Modifier {
    val alpha by animateFloatAsState(if (enabled) 0.5f else 1.0f, label = "alpha")
    return this then Modifier.graphicsLayer { this.alpha = alpha }
}

注意:CompositionLocal调用处解析,不是应用处;无法跳过重组。

方式三:Modifier.Node(最高性能和最灵活)

6.2 Modifier.Node 架构

由三部分组成:

// 1. 工厂:公共扩展函数
fun Modifier.circle(color: Color) = this then CircleElement(color)

// 2. 元素:不可变,持有参数(必须是 data class 以正确实现 equals/hashCode)
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)
    override fun update(node: CircleNode) { node.color = color }
}

// 3. 节点:有状态,存活于重组之间
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

6.3 Modifier.Node 常见场景

零参数节点

fun Modifier.myDebugBorder() = this then DebugBorderElement

private data object DebugBorderElement : ModifierNodeElement<DebugBorderNode>() {
    override fun create() = DebugBorderNode()
    override fun update(node: DebugBorderNode) {}
}

private class DebugBorderNode : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawRect(Color.Red, style = Stroke(2f))
        drawContent()
    }
}

读取 CompositionLocal

fun Modifier.localAwareBackground() = this then LocalAwareElement

private data object LocalAwareElement : ModifierNodeElement<LocalAwareNode>() {
    override fun create() = LocalAwareNode()
    override fun update(node: LocalAwareNode) {}
}

private class LocalAwareNode : DrawModifierNode,
    CompositionLocalConsumerModifierNode,
    Modifier.Node() {
    override fun ContentDrawScope.draw() {
        // 在使用处解析 CompositionLocal
        val theme = currentValueOf(LocalThemeColors)
        drawRect(theme.background)
        drawContent()
    }
}

带动画的节点

fun Modifier.animatedPulse() = this then PulseElement

private data object PulseElement : ModifierNodeElement<PulseNode>() {
    override fun create() = PulseNode()
    override fun update(node: PulseNode) {}
}

private class PulseNode : DrawModifierNode, Modifier.Node() {
    private val alpha = Animatable(0.5f)

    init {
        coroutineScope.launch {
            while (true) {
                alpha.animateTo(0.2f, tween(1000))
                alpha.animateTo(0.5f, tween(1000))
            }
        }
    }

    override fun ContentDrawScope.draw() {
        drawCircle(Color.Blue, alpha = alpha.value)
        drawContent()
    }
}

性能优化 — 手动失效

private class OptimizedNode : DrawModifierNode, Modifier.Node() {
    override var shouldAutoInvalidate: Boolean = false

    fun updateValue(newValue: Float) {
        // 只在值真正变化时触发重绘
        if (newValue != currentValue) {
            currentValue = newValue
            invalidateDraw()  // 手动触发绘制失效
        }
    }
}

6.4 选择哪种方式

场景

推荐方式

组合现有修饰符

链式组合

需要 animate*AsStateCompositionLocal

Composable 工厂

生产级、性能敏感、自定义绘制

Modifier.Node


第七部分:实战综合案例

7.1 自定义流式标签布局

结合测量约束和自动换行:

@Composable
fun FlowTagLayout(
    modifier: Modifier = Modifier,
    tagGap: Dp = 8.dp,
    rowGap: Dp = 4.dp,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        val tagGapPx = tagGap.roundToPx()
        val rowGapPx = rowGap.roundToPx()
        val maxWidth = constraints.maxWidth

        val placeableRows = mutableListOf<List<Placeable>>()
        var currentRow = mutableListOf<Placeable>()
        var currentRowWidth = 0

        measurables.forEach { measurable ->
            val placeable = measurable.measure(
                constraints.copy(minWidth = 0, minHeight = 0)
            )

            val neededWidth = if (currentRow.isEmpty()) {
                placeable.width
            } else {
                currentRowWidth + tagGapPx + placeable.width
            }

            if (neededWidth <= maxWidth && currentRow.isNotEmpty()) {
                currentRow.add(placeable)
                currentRowWidth = neededWidth
            } else {
                if (currentRow.isNotEmpty()) {
                    placeableRows.add(currentRow.toList())
                }
                currentRow = mutableListOf(placeable)
                currentRowWidth = placeable.width
            }
        }
        if (currentRow.isNotEmpty()) {
            placeableRows.add(currentRow.toList())
        }

        // 计算总尺寸
        val totalHeight = placeableRows.sumOf { row ->
            row.maxOfOrNull { it.height } ?: 0
        } + rowGapPx * (placeableRows.size - 1).coerceAtLeast(0)

        val layoutHeight = totalHeight.coerceAtMost(constraints.maxHeight)

        layout(maxWidth, layoutHeight) {
            var y = 0
            placeableRows.forEach { row ->
                val rowHeight = row.maxOf { it.height }
                var x = 0
                row.forEach { placeable ->
                    placeable.placeRelative(x, y)
                    x += placeable.width + tagGapPx
                }
                y += rowHeight + rowGapPx
            }
        }
    }
}

7.2 带基线对齐的表单布局

使用对齐线实现标签和输入框的精确对齐:

@Composable
fun AlignedFormRow(
    label: String,
    input: @Composable () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp),
        verticalAlignment = Alignment.Bottom
    ) {
        Text(
            text = label,
            modifier = Modifier
                .paddingFrom(FirstBaseline, top = 8.dp)
                .padding(end = 16.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        input()
    }
}

7.3 性能优化案例 — 列表项修饰符

使用 Modifier.Node 创建高性能的列表项悬停效果:

fun Modifier.listItemHover() = this then HoverElement

private data object HoverElement : ModifierNodeElement<HoverNode>() {
    override fun create() = HoverNode()
    override fun update(node: HoverNode) {}
}

private class HoverNode : DrawModifierNode, LayoutAwareModifierNode, Modifier.Node() {
    private val alpha = Animatable(0f)
    private var isHovered = false

    init {
        // 监听交互
        coroutineScope.launch {
            snapshotFlow { isHovered }
                .collect { hovered ->
                    if (hovered) {
                        alpha.animateTo(0.08f, tween(150))
                    } else {
                        alpha.animateTo(0f, tween(100))
                    }
                }
        }
    }

    override fun onPointerEvent(
        pointerEventType: PointerEventType,
        event: PointerEvent,
        bounds: IntRect
    ): PointerEventVerb {
        when (pointerEventType) {
            PointerEventType.Enter -> isHovered = true
            PointerEventType.Exit -> isHovered = false
            else -> Unit
        }
        return PointerEventVerb.NoOp
    }

    override fun ContentDrawScope.draw() {
        if (alpha.value > 0f) {
            drawRect(Color.Gray, alpha = alpha.value)
        }
        drawContent()
    }
}

结语

Compose 自定义布局的学习路径:

  1. 入门:从 layout 修饰符开始,理解测量 → 决定尺寸 → 放置的三步流程

  2. 进阶:掌握 Layout 可组合函数,构建自定义多子元素布局

  3. 高级:学习 SubcomposeLayout 实现条件组合,掌握对齐线和内部测量

  4. 精通:使用 Modifier.Node 构建生产级自定义修饰符

何时使用自定义布局

  • 内置布局(Column/Row/Box/LazyList)无法满足布局需求

  • 需要基于子元素内容做尺寸决策

  • 需要跨子元素的精确对齐

  • 需要高性能的自定义绘制/交互修饰符

何时避免

  • FlowRow/FlowColumn 可以解决的简单换行

  • Grid 可以处理的 2D 布局

  • LazyGrid 适合的大型数据集

Compose 的"measure once"约束看似限制,实则是性能保障。理解并顺应这个约束,你就能构建出既高效又灵活的自定义布局。


上一篇 夜雨初歇