Jetpack Compose 入门系列(五):自定义布局与 ConstraintLayout

8 阅读12分钟

Jetpack Compose 入门系列(五):自定义布局与 ConstraintLayout

学完上篇你已经能让界面"动起来"了,但遇到复杂布局还是只能 Column 套 Row、Row 套 Column,层数越嵌越深。本篇解决一个问题:当标准布局不够用时,如何自己掌控测量与排列


一、Compose 布局原理:测量与放置

在 XML 时代,自定义布局要写一个继承 ViewGroup 的类,重写 onMeasure()onLayout()——先测量子 View 多大,再把它们放到指定位置。Compose 的思路完全一样,只是写法不同:

flowchart TB
    A[父 Composable<br/>拿到可用空间约束] --> B[测量所有子组件<br/>Measurable.measure]
    B --> C[子组件返回自己的尺寸<br/>Measurable]
    C --> D[父组件根据子组件尺寸<br/>决定自己的大小]
    D --> E[放置子组件到指定位置<br/>placeable.place]

用一句话概括:先量尺寸,再定位置。不管用什么 API,底层都是这个流程。

1.1 三个核心概念

概念含义类比 XML
Measurable待测量的子组件ViewonMeasure 中的 child
MeasureScope测量环境,提供 measure() 方法MeasureSpec
Placeable测量完成的子组件,可以指定位置onLayout 中拿到 child 宽高后放置

1.2 Constraints:来自父组件的约束

在测量之前,父组件会给每个子组件一个 Constraints,告诉它"你最大可以多大、最小必须多大":

Constraints(
    minWidth = 0,     // 最小宽度
    maxWidth = 1080,  // 最大宽度
    minHeight = 0,    // 最小高度
    maxHeight = 1920  // 最大高度
)

这和 XML 里的 MeasureSpec.AT_MOSTMeasureSpec.EXACTLY 是同一回事,只是 Compose 用 Constraints 统一表达。


二、自定义 Layout Composable

2.1 基本写法

Layout 是 Compose 提供的自定义布局入口,相当于 XML 中的自定义 ViewGroup

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

        // 2. 计算自身大小
        val width = placeables.maxOfOrNull { it.width } ?: 0
        val height = placeables.sumOf { it.height }

        // 3. 放置子组件
        layout(width, height) {
            var yPosition = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                yPosition += placeable.height
            }
        }
    }
}

逐步拆解:

  1. measurables:所有子组件,还没测量,类型是 List<Measurable>
  2. constraints:父组件给的约束(最大/最小宽高)
  3. measurable.measure(constraints):测量子组件,返回 Placeable(已测量,有宽高)
  4. layout(width, height) { }:声明自身大小,并在 block 里放置子组件
  5. placeable.placeRelative(x, y):把子组件放在 (x, y) 位置,Relative 表示自动处理 RTL 布局

上面的代码效果等同于 Column——垂直排列子组件。但你能看到"测量 + 放置"每一步在做什么。

2.2 完整示例:纵向等间距布局

Column 的 Arrangement.SpaceEvenly 可以做到等间距,但如果需求是"第一个贴顶、最后一个贴底、中间等间距"呢?用 Column 的 Arrangement 做不到,自定义 Layout 只需几行:

@Composable
fun EqualSpaceColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        val placeables = measurables.map { it.measure(constraints) }

        val totalHeight = constraints.maxHeight
        val childrenHeight = placeables.sumOf { it.height }
        val spacing = if (measurables.size > 1) {
            (totalHeight - childrenHeight).toFloat() / (measurables.size - 1)
        } else {
            0f
        }

        layout(constraints.maxWidth, totalHeight) {
            var y = 0f
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = y.roundToInt())
                y += placeable.height + spacing
            }
        }
    }
}

使用方式和普通 Column 一样:

@Composable
fun EqualSpaceColumnDemo() {
    EqualSpaceColumn(
        modifier = Modifier
            .fillMaxWidth()
            .height(300.dp)
            .background(Color(0xFFF5F5F5))
    ) {
        Text("第一个", modifier = Modifier.background(Color.Red.copy(alpha = 0.3f)))
        Text("第二个", modifier = Modifier.background(Color.Green.copy(alpha = 0.3f)))
        Text("第三个", modifier = Modifier.background(Color.Blue.copy(alpha = 0.3f)))
    }
}

这段代码里发生了什么:

  1. 父组件给了 constraints.maxHeight = 300dp 的约束
  2. 测量三个 Text,得到各自的实际高度
  3. 计算间距:(总高度 - 子组件高度之和) / (子组件数 - 1)
  4. 放置时,每个子组件的 y 坐标 = 上一个的 y + 上一个的高度 + 间距

placeRelative 会自动处理 RTL(从右到左)布局。如果你用 place,在 RTL 环境下 x 坐标不会自动镜像。除非有特殊需求,始终用 placeRelative

2.3 示例:瀑布流布局

瀑布流(Staggered Grid)是 Compose 标准布局里没有的,但自定义 Layout 实现起来并不复杂:

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 2,
    horizontalGap: Dp = 8.dp,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        val gapPx = horizontalGap.roundToPx()
        val rowWidth = (constraints.maxWidth - gapPx * (rows - 1)) / rows
        val rowConstraints = constraints.copy(
            minWidth = rowWidth,
            maxWidth = rowWidth
        )

        // 按行分组
        val rowGroups = measurables.mapIndexed { index, measurable ->
            val placeable = measurable.measure(rowConstraints)
            RowItem(row = index % rows, placeable = placeable)
        }

        // 计算每行的 y 偏移量(上一行最后一个 item 的底部)
        val rowHeights = IntArray(rows) { 0 }
        rowGroups.forEach { item ->
            val currentRowHeight = rowHeights[item.row]
            rowHeights[item.row] = currentRowHeight + item.placeable.height
        }

        val totalHeight = rowHeights.maxOrNull() ?: 0

        layout(constraints.maxWidth, totalHeight) {
            // 重置每行当前 y 偏移
            val currentY = IntArray(rows) { 0 }
            rowGroups.forEach { item ->
                val x = item.row * (rowWidth + gapPx)
                val y = currentY[item.row]
                item.placeable.placeRelative(x = x, y = y)
                currentY[item.row] = y + item.placeable.height
            }
        }
    }
}

private data class RowItem(
    val row: Int,
    val placeable: Placeable
)

使用:

@Composable
fun StaggeredGridDemo() {
    val items = listOf("短文字", "这是一段比较长的文字内容", "中等", "又一段长文字,占据更多空间", "短", "最后一条")

    StaggeredGrid(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        rows = 2,
        horizontalGap = 8.dp
    ) {
        items.forEach { text ->
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            ) {
                Text(
                    text = text,
                    modifier = Modifier.padding(12.dp),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

这段代码里发生了什么:

  1. 把总宽度按列数平分,每列宽度 = (总宽 - 间距) / 列数
  2. index % rows 决定每个 item 放在哪一列(交错排列)
  3. 每列独立追踪当前 y 偏移,实现瀑布流效果

注意 RowItem 是个 data class,用于把测量结果和行号绑定在一起。这是自定义 Layout 中常见的模式——测量阶段收集信息,放置阶段使用信息


三、Modifier.layout:自定义布局修饰符

3.1 它是什么

Layout composable 是创建一个新的布局容器。但有时候你只是想修改某个组件自身的测量或位置——比如给组件加内边距、居中偏移、等比缩放。这时用 Modifier.layout() 更合适。

Modifier.layout { measurable, constraints ->
    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) {
        placeable.placeRelative(0, 0)
    }
}

对比一下两种方式的区别:

Layout ComposableModifier.layout
作用对象包裹多个子组件修改单个组件
类比 XML自定义 ViewGroup自定义 LayoutParams
典型场景自定义排列方式内边距、偏移、缩放等修饰

3.2 完整示例:自定义内边距修饰符

Compose 自带 Modifier.padding(),但我们用 Modifier.layout() 手写一个,理解它底层在做什么:

fun Modifier.customPadding(
    start: Dp = 0.dp,
    top: Dp = 0.dp,
    end: Dp = 0.dp,
    bottom: Dp = 0.dp
) = this.then(Modifier.layout { measurable, constraints ->
    val startPx = start.roundToPx()
    val topPx = top.roundToPx()
    val endPx = end.roundToPx()
    val bottomPx = bottom.roundToPx()

    // 给子组件缩小可用空间
    val childConstraints = constraints.copy(
        minWidth = (constraints.minWidth - startPx - endPx).coerceAtLeast(0),
        maxWidth = (constraints.maxWidth - startPx - endPx).coerceAtLeast(0),
        minHeight = (constraints.minHeight - topPx - bottomPx).coerceAtLeast(0),
        maxHeight = (constraints.maxHeight - topPx - bottomPx).coerceAtLeast(0)
    )

    val placeable = measurable.measure(childConstraints)

    // 自身大小 = 子组件大小 + 内边距
    val width = placeable.width + startPx + endPx
    val height = placeable.height + topPx + bottomPx

    layout(width, height) {
        // 子组件位置偏移内边距
        placeable.placeRelative(startPx, topPx)
    }
})

使用:

@Composable
fun CustomPaddingDemo() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.LightGray)
    ) {
        Text(
            text = "Hello Compose",
            modifier = Modifier
                .customPadding(start = 16.dp, top = 24.dp)
                .background(Color.Cyan.copy(alpha = 0.5f))
        )
    }
}

这段代码里发生了什么:

  1. customPadding 把父组件给的约束缩小(减去内边距),传给子组件
  2. 子组件在缩小后的空间里测量自己
  3. 自身大小 = 子组件大小 + 内边距
  4. 放置子组件时偏移内边距的距离

coerceAtLeast(0) 防止内边距比可用空间还大时出现负数——这和 Modifier.padding() 内部的处理逻辑一致。

3.3 示例:垂直居中修饰符

fun Modifier.verticalCenter() = this.then(Modifier.layout { measurable, constraints ->
    val placeable = measurable.measure(constraints)
    layout(constraints.maxWidth, constraints.maxHeight) {
        val y = (constraints.maxHeight - placeable.height) / 2
        placeable.placeRelative(0, y)
    }
})

使用:

@Composable
fun VerticalCenterDemo() {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(100.dp)
            .background(Color(0xFFF0F0F0))
    ) {
        Text(
            text = "垂直居中",
            modifier = Modifier.verticalCenter()
        )
    }
}

Modifier.layout 的典型用法:不改变子组件的大小,只改变它的位置。这个思路和 CSS 的 transform: translateY(50%) 类似——不影响文档流,只做视觉偏移。


四、ConstraintLayout:约束布局

4.1 为什么需要 ConstraintLayout

Column + Row 能解决大部分布局,但嵌套层数多了会影响性能(每层都要测量一次),而且代码可读性差。ConstraintLayout 可以让你在一个扁平层级里完成复杂布局——和 XML 里的 ConstraintLayout 是同一个东西。

flowchart TB
    subgraph 嵌套布局
        A[Column] --> B[Row]
        B --> C[Text]
        B --> D[Image]
        A --> E[Row]
        E --> F[Button]
        E --> G[Text]
    end

    subgraph ConstraintLayout
        H[ConstraintLayout] --> I[Text]
        H --> J[Image]
        H --> K[Button]
        H --> L[Text]
    end

    嵌套布局 -.->|扁平化| ConstraintLayout

4.2 添加依赖

gradle/libs.versions.toml

[versions]
constraintLayout = "1.1.1"

[libraries]
androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintLayout" }

app/build.gradle.kts

dependencies {
    implementation(libs.androidx.constraintlayout.compose)
}

4.3 基本用法:通过引用和约束来布局

ConstraintLayout 的核心思路是:给每个子组件创建一个引用,然后用约束把它们"绑"到相对位置上

@Composable
fun ConstraintLayoutDemo() {
    ConstraintLayout(
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp)
    ) {
        // 创建引用
        val (title, subtitle, avatar) = createRefs()

        // 头像:左上角
        Image(
            painter = painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = null,
            modifier = Modifier
                .size(60.dp)
                .constrainAs(avatar) {
                    start.linkTo(parent.start, margin = 16.dp)
                    top.linkTo(parent.top, margin = 16.dp)
                }
        )

        // 标题:头像右侧、垂直居中
        Text(
            text = "Jetpack Compose",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.constrainAs(title) {
                start.linkTo(avatar.end, margin = 12.dp)
                top.linkTo(avatar.top)
                bottom.linkTo(avatar.bottom)
            }
        )

        // 副标题:标题下方
        Text(
            text = "声明式 UI 框架",
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = Modifier.constrainAs(subtitle) {
                start.linkTo(title.start)
                top.linkTo(avatar.bottom, margin = 8.dp)
            }
        )
    }
}

逐步拆解:

  1. createRefs():创建引用,数量和子组件一一对应
  2. constrainAs(ref) { }:把约束绑定到某个组件上
  3. start.linkTo(parent.start, margin = 16.dp):组件的左边,对齐到父组件的左边,间距 16dp
  4. top.linkTo(avatar.bottom):组件的顶部,对齐到头像的底部

linkTo 就是 XML 里 app:layout_constraintStart_toStartOf 的 Compose 写法。左边约束到谁、上边约束到谁——思路完全一样。

4.4 约束速查表

XML 属性Compose 写法
app:layout_constraintStart_toStartOfstart.linkTo(ref.start)
app:layout_constraintEnd_toEndOfend.linkTo(ref.end)
app:layout_constraintTop_toTopOftop.linkTo(ref.top)
app:layout_constraintBottom_toBottomOfbottom.linkTo(ref.bottom)
app:layout_constraintStart_toEndOfstart.linkTo(ref.end)
android:layout_marginStartstart.linkTo(ref.start, margin = 8.dp)
app:layout_constraintHorizontal_biashorizontalBias = 0.3f
app:layout_constraintVertical_biasverticalBias = 0.7f
app:layout_constraintWidth_percentwidth = Dimension.percent(0.5f)
app:layout_constraintWidth_matchConstraintwidth = Dimension.fillToConstraints
app:layout_constraintWidth_wrapContentwidth = Dimension.wrapContent

4.5 Barrier 和 Guideline

这两个是 ConstraintLayout 的"辅助线",帮你处理更复杂的对齐场景。

Guideline(参考线):在布局中画一条虚拟的线,组件可以约束到这条线上:

@Composable
fun GuidelineDemo() {
    ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
        val (leftText, rightText) = createRefs()
        val halfGuideline = createGuidelineFromStart(fraction = 0.5f)

        Text(
            text = "左半区",
            modifier = Modifier.constrainAs(leftText) {
                end.linkTo(halfGuideline, margin = 8.dp)
                top.linkTo(parent.top)
            }
        )

        Text(
            text = "右半区",
            modifier = Modifier.constrainAs(rightText) {
                start.linkTo(halfGuideline, margin = 8.dp)
                top.linkTo(parent.top)
            }
        )
    }
}

Barrier(屏障):根据一组组件的边界动态生成一条线。比如两个文字长度不固定,你希望右边按钮始终在"两个文字中更长的那个"的右侧:

@Composable
fun BarrierDemo() {
    ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
        val (nameLabel, descLabel, valueText) = createRefs()
        val endBarrier = createEndBarrier(nameLabel, descLabel)

        Text(
            text = "用户名",
            modifier = Modifier.constrainAs(nameLabel) {
                start.linkTo(parent.start, margin = 16.dp)
                top.linkTo(parent.top, margin = 16.dp)
            }
        )

        Text(
            text = "个人描述(可能很长)",
            modifier = Modifier.constrainAs(descLabel) {
                start.linkTo(parent.start, margin = 16.dp)
                top.linkTo(nameLabel.bottom, margin = 8.dp)
            }
        )

        Text(
            text = "值",
            modifier = Modifier.constrainAs(valueText) {
                start.linkTo(endBarrier, margin = 16.dp)
                top.linkTo(parent.top, margin = 16.dp)
            }
        )
    }
}

createEndBarrier(nameLabel, descLabel) 创建一条"右边界屏障"——它始终在 nameLabel 和 descLabel 中靠右更远的那个的右侧。当 descLabel 比 nameLabel 宽时,屏障自动跟着右移。

4.6 ConstraintSet:解耦约束与组件

当约束很复杂时,把约束定义和 UI 组件分开写会更清晰。ConstraintSet 就是干这个的:

private fun profileConstraintSet(): ConstraintSet {
    return ConstraintSet {
        val avatar = createRefFor("avatar")
        val name = createRefFor("name")
        val desc = createRefFor("desc")
        val divider = createRefFor("divider")

        constrain(avatar) {
            start.linkTo(parent.start, margin = 16.dp)
            top.linkTo(parent.top, margin = 16.dp)
        }

        constrain(name) {
            start.linkTo(avatar.end, margin = 12.dp)
            top.linkTo(avatar.top)
            end.linkTo(parent.end, margin = 16.dp)
            width = Dimension.fillToConstraints
        }

        constrain(desc) {
            start.linkTo(name.start)
            top.linkTo(name.bottom, margin = 4.dp)
            end.linkTo(parent.end, margin = 16.dp)
            width = Dimension.fillToConstraints
        }

        constrain(divider) {
            start.linkTo(parent.start)
            end.linkTo(parent.end)
            top.linkTo(avatar.bottom, margin = 16.dp)
            width = Dimension.fillToConstraints
        }
    }
}

@Composable
fun ConstraintSetDemo() {
    ConstraintLayout(
        constraintSet = profileConstraintSet(),
        modifier = Modifier.fillMaxWidth()
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = null,
            modifier = Modifier
                .size(48.dp)
                .layoutId("avatar")
        )
        Text(
            text = "张三",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.layoutId("name")
        )
        Text(
            text = "Android 开发者",
            style = MaterialTheme.typography.bodySmall,
            modifier = Modifier.layoutId("desc")
        )
        HorizontalDivider(modifier = Modifier.layoutId("divider"))
    }
}

这段代码里发生了什么:

  1. ConstraintSet 里用 createRefFor("avatar") 创建字符串引用,不用 createRefs()
  2. 组件用 Modifier.layoutId("avatar") 和约束集里的引用匹配
  3. 约束逻辑和 UI 组件完全分离——可以独立修改约束而不动组件代码

ConstraintSet 的最大价值:约束可以动态切换。比如横屏和竖屏用不同的 ConstraintSet,组件代码完全不变,只换约束。


五、自定义 Layout vs ConstraintLayout:怎么选

场景推荐原因
标准 UI 排版(表单、列表、卡片)Column/Row/Box最简单,够用
多组件相对定位、避免深层嵌套ConstraintLayout扁平化,约束表达清晰
标准布局无法满足的特殊排列自定义 Layout完全控制测量和放置
对单个组件做测量/位置微调Modifier.layout轻量,不引入新容器
需要根据子组件尺寸动态决定布局自定义 LayoutConstraintLayout 无法在测量阶段做计算
flowchart TD
    A[需要自定义布局?] --> B{怎么排?}
    B -->|标准排列| C[Column / Row / Box]
    B -->|相对定位| D[ConstraintLayout]
    B -->|特殊排列| E[自定义 Layout]
    B -->|单组件微调| F[Modifier.layout]
    B -->|动态计算| G[自定义 Layout]

不要为了用 ConstraintLayout 而用——3 个组件以内用 Column/Row 更清晰。ConstraintLayout 的优势在于组件多、约束复杂时减少嵌套。


六、综合实战:个人主页

这个 Demo 把前面所有知识点串起来——自定义 Layout、Modifier.layout、ConstraintLayout(含 Barrier 和 Guideline),一个不落。

// ---------- 自定义布局:标签流式换行 ----------
// 注意:Compose 1.4+ 内置了 FlowRow,这里自己实现是为了理解 Layout 原理
@Composable
private fun CustomFlowRow(
    modifier: Modifier = Modifier,
    horizontalGap: Dp = 8.dp,
    verticalGap: Dp = 8.dp,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        val hGapPx = horizontalGap.roundToPx()
        val vGapPx = verticalGap.roundToPx()

        val placeables = measurables.map { it.measure(constraints) }

        var currentRowWidth = 0
        var currentRowHeight = 0
        var totalHeight = 0
        val rows = mutableListOf<List<Placeable>>()
        val currentRow = mutableListOf<Placeable>()

        for (placeable in placeables) {
            if (currentRowWidth + placeable.width > constraints.maxWidth && currentRow.isNotEmpty()) {
                rows.add(currentRow.toList())
                totalHeight += currentRowHeight + vGapPx
                currentRow.clear()
                currentRowWidth = 0
                currentRowHeight = 0
            }
            currentRow.add(placeable)
            currentRowWidth += placeable.width + hGapPx
            currentRowHeight = maxOf(currentRowHeight, placeable.height)
        }

        if (currentRow.isNotEmpty()) {
            rows.add(currentRow.toList())
            totalHeight += currentRowHeight
        }

        layout(constraints.maxWidth, totalHeight) {
            var y = 0
            for (row in rows) {
                var x = 0
                var rowHeight = 0
                for (placeable in row) {
                    placeable.placeRelative(x, y)
                    x += placeable.width + hGapPx
                    rowHeight = maxOf(rowHeight, placeable.height)
                }
                y += rowHeight + vGapPx
            }
        }
    }
}

// ---------- 主页面 ----------
@Composable
fun ProfilePageDemo() {
    val tags = listOf("Kotlin", "Compose", "Android", "MVVM", "Coroutines", "Retrofit")

    Column(modifier = Modifier.fillMaxSize()) {
        // 顶部封面区域:ConstraintLayout + Guideline
        ConstraintLayout(
            modifier = Modifier
                .fillMaxWidth()
                .height(180.dp)
        ) {
            val (coverBg, avatar, nameText, roleText) = createRefs()
            val bottomGuideline = createGuidelineFromBottom(fraction = 0.35f)

            Box(
                modifier = Modifier
                    .constrainAs(coverBg) {
                        top.linkTo(parent.top)
                        start.linkTo(parent.start)
                        end.linkTo(parent.end)
                        bottom.linkTo(bottomGuideline)
                    }
                    .background(
                        Brush.verticalGradient(
                            colors = listOf(
                                Color(0xFF6200EE),
                                Color(0xFFBB86FC)
                            )
                        )
                    )
            )

            Image(
                painter = painterResource(id = R.drawable.ic_launcher_foreground),
                contentDescription = null,
                modifier = Modifier
                    .size(72.dp)
                    .clip(CircleShape)
                    .border(2.dp, Color.White, CircleShape)
                    .constrainAs(avatar) {
                        start.linkTo(parent.start, margin = 24.dp)
                        bottom.linkTo(bottomGuideline)
                    }
            )

            Text(
                text = "张三",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold,
                color = Color.White,
                modifier = Modifier.constrainAs(nameText) {
                    start.linkTo(avatar.end, margin = 16.dp)
                    bottom.linkTo(avatar.bottom, margin = 24.dp)
                }
            )

            Text(
                text = "Android 开发者",
                style = MaterialTheme.typography.bodyMedium,
                color = Color.White.copy(alpha = 0.8f),
                modifier = Modifier.constrainAs(roleText) {
                    start.linkTo(nameText.start)
                    top.linkTo(nameText.bottom, margin = 2.dp)
                }
            )
        }

        // 信息卡片区域:ConstraintLayout + Barrier
        ConstraintLayout(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 8.dp)
        ) {
            val (label1, value1, label2, value2, label3, value3) = createRefs()
            val valueBarrier = createEndBarrier(label1, label2, label3)

            Text(
                text = "城市",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.constrainAs(label1) {
                    start.linkTo(parent.start)
                    top.linkTo(parent.top)
                }
            )
            Text(
                text = "北京",
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.constrainAs(value1) {
                    start.linkTo(valueBarrier, margin = 24.dp)
                    top.linkTo(label1.top)
                }
            )

            Text(
                text = "经验",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.constrainAs(label2) {
                    start.linkTo(parent.start)
                    top.linkTo(label1.bottom, margin = 8.dp)
                }
            )
            Text(
                text = "5 年",
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.constrainAs(value2) {
                    start.linkTo(valueBarrier, margin = 24.dp)
                    top.linkTo(label2.top)
                }
            )

            Text(
                text = "技术栈",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.constrainAs(label3) {
                    start.linkTo(parent.start)
                    top.linkTo(label2.bottom, margin = 8.dp)
                }
            )
            Text(
                text = "Kotlin / Compose",
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.constrainAs(value3) {
                    start.linkTo(valueBarrier, margin = 24.dp)
                    top.linkTo(label3.top)
                }
            )
        }

        HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

        // 技能标签区域:自定义 FlowRow
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "技能标签",
                style = MaterialTheme.typography.titleSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Spacer(modifier = Modifier.height(8.dp))

            CustomFlowRow(horizontalGap = 8.dp, verticalGap = 8.dp) {
                tags.forEach { tag ->
                    SuggestionChip(
                        onClick = { },
                        label = { Text(tag, style = MaterialTheme.typography.bodySmall) }
                    )
                }
            }
        }

        // 简介卡片:Modifier.layout 实现自定义内边距
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp)
                .customPadding(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.surfaceVariant
            )
        ) {
            Text(
                text = "5 年 Android 开发经验,专注 Jetpack Compose 和现代 Android 开发。" +
                        "热爱开源,喜欢用 Kotlin 解决实际问题。",
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.padding(12.dp)
            )
        }
    }
}

实战知识点对应表

实战中的效果使用的 API对应章节
标签流式换行排列自定义 Layout(CustomFlowRow)
信息标签右侧对齐ConstraintLayout + Barrier
封面区域定位ConstraintLayout + Guideline
简介卡片自定义内边距Modifier.layout(customPadding)

七、总结

本篇你学到了 Compose 自定义布局的核心能力:

  1. 布局原理:先测量(measure)、再放置(place),和 XML 的 onMeasure/onLayout 一脉相承
  2. Layout Composable:创建自定义布局容器,完全控制子组件的测量和位置
  3. Modifier.layout:对单个组件做测量/位置微调,类似自定义 LayoutParams
  4. ConstraintLayout:扁平化复杂布局,用约束替代嵌套;BarrierGuideline 处理动态对齐
  5. ConstraintSet:约束与组件解耦,便于动态切换约束
  6. 选型指南:简单用 Column/Row、复杂用 ConstraintLayout、特殊用自定义 Layout

核心原则:能用标准布局就用标准布局,遇到标准布局搞不定的场景再上自定义 Layout 或 ConstraintLayout。不要为了炫技而把简单问题复杂化。

下一篇我们将学习 Navigation 导航——如何在多个页面之间跳转、传参、管理返回栈,把单页应用变成多页应用。


如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。

系列文章: