Jetpack Compose 自定义布局解析

在 Jetpack Compose 中,虽然 RowColumnBox 能满足 90% 的布局需求,但当你需要实现瀑布流、标签云、或者复杂的重叠效果时,就需要用到自定义布局 (Custom Layout)

Compose 的布局系统相比传统 View 系统更加简洁高效(摒弃了多次测量机制)。


1. 布局原理

在 Compose 中,每个 UI 节点的布局过程分为三个阶段:

  1. 测量 (Measure): 父组件问子组件:"你需要多大地方?"(传入 Constraints)
  2. 决定尺寸 (Decide Size): 子组件根据约束和自身内容,告诉父组件:"我这么大。"(返回 Placeable)
  3. 放置 (Place) : 父组件拿到子组件的尺寸后,决定把它放在哪里(调用 placeRelative)。

核心原则:单次测量

Compose 禁止对同一个子组件进行多次测量(Intrinsic Size 除外),这极大提高了性能,避免了 View 体系中嵌套布局导致的指数级性能衰退。


2. 核心 API:Layout Composable

自定义布局的核心是 Layout 函数。

Kotlin 复制代码
@Composable
fun MyCustomLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // 1. 测量所有子组件
        // 2. 计算自身尺寸
        // 3. 放置子组件
        layout(width, height) { ... }
    }
}
  • measurables: 待测量的子组件列表。
  • constraints: 父组件传来的约束(最大/最小宽高)。
  • placeables: 测量后得到的子组件对象(包含宽高信息)。

3. 实战:实现一个简易的"瀑布流" (StaggeredGrid)

我们将实现一个垂直滚动的瀑布流布局,支持指定列数。

3.1 代码实现

Kotlin 复制代码
@Composable
fun VerticalStaggeredGrid(
    columns: Int, // 列数
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->

        // --- 步骤 1:测量子组件 ---

        // 计算每一列的宽度(总宽 / 列数)
        val itemWidth = constraints.maxWidth / columns
        // 创建约束:强制每个子项宽度固定,高度不限
        val itemConstraints = constraints.copy(
            minWidth = itemWidth,
            maxWidth = itemWidth
        )

        // 测量所有子项
        val placeables = measurables.map { measurable ->
            measurable.measure(itemConstraints)
        }

        // --- 步骤 2:计算位置 ---

        // 记录每一列当前的高度,初始都为 0
        val columnHeights = IntArray(columns)

        // 记录每个子项的 (x, y) 坐标
        val placeableXY = placeables.map { placeable ->
            // 算法:找到当前高度最小的那一列
            val minHeightColumnIndex = columnHeights.indexOf(columnHeights.minOrNull() ?: 0)

            val x = minHeightColumnIndex * itemWidth
            val y = columnHeights[minHeightColumnIndex]

            // 更新该列的高度
            columnHeights[minHeightColumnIndex] += placeable.height

            Pair(x, y) // 返回坐标
        }

        // --- 步骤 3:布局自身与放置子项 ---

        // 自身高度 = 最高那一列的高度
        val layoutHeight = columnHeights.maxOrNull() ?: constraints.minHeight

        layout(
            width = constraints.maxWidth,
            height = layoutHeight
        ) {
            // 放置每一个子项
            placeables.forEachIndexed { index, placeable ->
                val (x, y) = placeableXY[index]
                placeable.placeRelative(x = x, y = y)
            }
        }
    }
}

3.2 使用示例

为了让瀑布流可以滚动,我们需要在外部添加 verticalScroll 修饰符。同时,为了让它从顶部开始显示,我们通常将其放在一个填充父容器的 Column 或 Box 中。

Kotlin 复制代码
@Composable
fun StaggeredGridDemo() {
    // 1. 准备数据
    val items = remember {
        List(20) { index ->
            // 随机高度模拟不同大小的图片/卡片
            Random.nextInt(100, 300).dp
        }
    }

    // 2. 外层容器
    Box(
        modifier = Modifier
            .fillMaxSize() // 占满全屏
            .background(Color.White)
    ) {
        // 3. 使用自定义布局
        VerticalStaggeredGrid(
            columns = 2,
            modifier = Modifier
                .fillMaxWidth()
                .verticalScroll(rememberScrollState()) // 【关键】添加滚动能力
                .padding(8.dp) // 外边距
        ) {
            items.forEachIndexed { index, height ->
                Card(
                    modifier = Modifier
                        .padding(4.dp) // item 间距
                        .height(height) // 应用随机高度
                        .fillMaxWidth(), // 宽度填满列宽
                ) {
                    Box(contentAlignment = Alignment.Center) {
                        Text(
                            text = "Item $index",
                            color = Color.White,
                            style = MaterialTheme.typography.h6
                        )
                    }
                }
            }
        }
    }
}

4. 进阶:使用 LayoutModifier

有时候我们不需要写一个新的容器,只是想修改某个组件的尺寸或位置,这时可以使用 Modifier.layout

例如,实现一个忽略父组件 Padding 的全宽组件

Kotlin 复制代码
fun Modifier.ignoreParentPadding(horizontalPadding: Dp) = this.layout { measurable, constraints ->
    // 1. 测量:让子组件宽度增加 2倍 padding(抵消父组件的 padding)
    val placeable = measurable.measure(
        constraints.copy(
            maxWidth = constraints.maxWidth + (horizontalPadding.roundToPx() * 2)
        )
    )

    // 2. 布局
    layout(placeable.width, placeable.height) {
        // 3. 放置:向左偏移 padding 距离
        placeable.placeRelative(-horizontalPadding.roundToPx(), 0)
    }
}

5. 进阶:SubcomposeLayout (子组合布局)

如果你需要根据第一个子组件的尺寸来决定第二个子组件的内容 (例如:根据文本高度决定是否显示"展开/收起"按钮),普通的 Layout 做不到,因为测量和组合是分开的。

这时需要 SubcomposeLayout,它允许你在测量过程中动态组合新的 UI。BoxWithConstraintsLazyColumn 底层都用了它。但请注意,它的性能开销比普通 Layout 大,非必要不使用。


总结

  1. Layout Lambda : 核心是 measure (测量子项) -> 计算坐标 -> layout (指定自身尺寸) -> place (放置子项)。
  2. Constraints: 约束自上而下传递,尺寸自下而上上报。
  3. 单次测量: 保证了 Compose 布局的高效性。

掌握自定义布局,你就不再受限于官方提供的基础容器,能够自由绘制任何设计稿要求的 UI 结构。

相关推荐
alexhilton6 小时前
Compose中的ContentScale:终极可视化指南
android·kotlin·android jetpack
jzlhll1237 小时前
kotlin Flow first() last()总结
开发语言·前端·kotlin
符哥200812 小时前
充电桩 WiFi 局域网配网(Android/Kotlin)流程、指令及实例说明文档
android·开发语言·kotlin
大傻^15 小时前
SpringAI2.0 Null Safety 实战:JSpecify 注解体系与 Kotlin 互操作
android·开发语言·人工智能·kotlin·springai
jzlhll12320 小时前
Kotlin Mutex vs Java ReentrantLock vs synchronized
java·开发语言·kotlin
Kapaseker20 小时前
一杯 Kotlin 美式品味 object 声明
android·kotlin
俩个逗号。。21 小时前
Kotlin 扩展函数详解
开发语言·kotlin
su1ka1112 天前
Kotlin(3)基本语法
kotlin
su1ka1112 天前
Kotlin(4)面向对象
kotlin
鹧鸪晏2 天前
搞懂 kotlin 泛型 out 和 in 关键字
android·kotlin