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 结构。

相关推荐
Android-Flutter20 小时前
android compose LazyColumn 垂直列表滚动 使用
android·kotlin
Kapaseker1 天前
初级与中级的Android面试题区别在哪里
android·kotlin
zFox1 天前
二、Kotlin高级特性以及Compose状态驱动UI
ui·kotlin·compose
PuddingSama2 天前
Gson 很好,但在Kotlin上有更合适的序列化工具「Kotlin Serialization」
android·kotlin·gson
郑梓斌2 天前
Luban 2:简洁高效的Android图片压缩库
微信·kotlin
我命由我123452 天前
Android Jetpack Compose - Compose 重组、AlertDialog、LazyColumn、Column 与 Row
android·java·java-ee·kotlin·android studio·android jetpack·android-studio
愤怒的代码2 天前
在 Android 中执行 View.invalidate() 方法后经历了什么
android·java·kotlin
Android-Flutter2 天前
android compose PullToRefreshAndLoadMore 下拉刷新 + 上拉加载更多 使用
android·kotlin