Jetpack Compose 自定义布局:Layout Composable 深度剖析

325 阅读6分钟

回顾原生的自定义布局

Compose 中的自定义布局相较于原生的需求程度降低了很多,为什么呢?

原生的布局实现方式有两种:

  1. 使用 XML 文件来写
  2. 编写代码,动态地创建 View,并添加到 ViewGroup 中,这涉及了自定义 View

那什么时候我们会选择自定义 View 来写布局呢?主要有两种情况:

  • 当 XML 不够方便时

    比如我们现在想要修改某个组件的内容,但是它被嵌套进了布局深层,虽然我们可以通过多次 findViewById 来获取这个组件的实例,不过这不仅麻烦还容易出错。这时,我们就可以通过自定义 View,提供 API 接口(如 setPersonalSignatureText(text:String)),这样只需一行代码,就能修改组件内容。

  • 当 XML 做不到时

    比如我们想要一个组件在纵向填满可用高度,在这个前提下,让宽度与高度保持一致,你使用 XML 是做不到这一点的。只有当组件的宽高是固定的,你才能使用 XML 完成。这时你可以通过自定义 View 重写 onMeasure 方法,来完成上述需求。

    又或者你不满足现有组件的布局时(如 LinearLayout),你也可以通过自定义 View,来重写onMeasureonLayout 方法,定制布局算法。onMeasure 负责测量所有子 View 的尺寸,并根据子 View 的尺寸计算出自身的尺寸;onLayout 则负责根据测量结果,确定每个子 View 的最终位置并进行摆放。

Compose 的自定义布局

而到了 Compose,所有界面的构建都是通过代码完成的,但这能叫自定义布局吗?像这样:

@Preview
@Composable
fun FakeCustomLayout() {
    Column(Modifier.background(Color.White)) {
        Text("我真的是自定义布局吗?")
        Button(onClick = {}) {
            Text("点击我,并没有什么效果")
        }
    }
}
image.png

如果这都叫自定义布局,那么 Compose 里所有的布局都是“自定义”布局了,这也就没有讨论的意义了。

更准确地说,虽然这也是在使用代码构建布局,但使用的都是现成的布局组件,这些布局规则都是固定的。所以 Compose 中的自定义布局指的是对布局算法的定制,我们自己去定义子组件如何被测量和摆放的规则。

这种“定制”布局的方式有两种:

  1. Modifier.layout 函数

    它可以通过影响单个 Composable 组件的测量和布局,来改变单个组件的尺寸和位置。例如给某个组件添加额外的边距。

  2. Layout Composable函数

    它可以控制子组件的测量和摆放逻辑,来改变每个子组件的尺寸和位置。从而我们就可以定制出我们想要的布局组件,比如一个类似 Column 的布局。

因为 layout 函数我们之前已经讲解过了,所以接下来看看 Layout 函数。

Layout 的结构应该是什么样的?

首先它是用于自定义布局的,所以我们先创建一个自定义的 Composable 函数,在内部调用 Layout 函数:

@Composable
fun CustomLayout() {
    // 调用 Layout Composable,传入其 lambda,包含测量和布局逻辑
    Layout() { measurables, constraints -> // measurables: 子组件列表, constraints: 父布局约束
        // layout() 函数用于确定自身尺寸并摆放子组件
        // 初始尺寸暂定为 0,后续会根据子项实际测量结果计算
        layout(width = 0, height = 0) {
            // 此处进行子组件的摆放
        }
    }
}

其中 measurables 代表所有子组件的可测量对象,constraints 为父容器传递过来的约束。调用 layout() 函数是为了摆放子组件,并确定自身位置。

然后呢,为自定义 Composable 函数添加 content 参数,再提供给 Layout 函数,使我们的自定义布局能够接收外部传入的子组件,这是关键。因为你要有内容,才能对内容进行布局。

@Composable
fun CustomLayout(
    content: @Composable () -> Unit // content 参数,用于从外部接收子 Composable
) {
    // 将 content 传递给 Layout Composable
    Layout(content = content) { measurables, constraints -> // measurables 现在会包含 content 中的组件
        // 同样需要测量和布局
        layout(width = 0, height = 0) {
            // 子组件摆放逻辑
        }
    }
}

最后 Layout 还有一个可选参数 modifier,我们补充一下。

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier, // modifier 参数允许外部对自定义布局进行修饰
    content: @Composable () -> Unit // content 参数用于接收子组件
) {
    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->
        // measurables: 子组件的可测量对象列表
        // constraints: 父容器传递的布局约束

        // 核心逻辑:测量子组件,计算自身尺寸,摆放子组件
        layout(width = 0 /* 实际宽度根据子项计算得出 */, height = 0 /* 实际高度根据子项计算得出 */) {
            // 在这里根据测量结果放置子组件
        }
    }
}

这就是最终的结构了。

实现类似 Column 的布局

现在就来看看它的写法。

首先把内部的子组件都测量一下,并将每次测量的测量结果都保存到集合中。

// 测量所有子组件
val placeables: List<Placeable> = measurables.map { measurable ->
    // 对每个子 Composable (measurable) 调用 measure 方法,并传入约束
    // measure 方法会返回一个 Placeable 对象,它代表了已测量的子组件,包含其尺寸信息
    measurable.measure(constraints)
}

然后计算出自身应有的尺寸,宽度为子组件的最大宽度,高度为所有子组件的高度之和。

// 计算自身尺寸 (基于子组件的测量结果)
// 宽度:取所有已测量子组件 (placeables) 中的最大宽度。如果列表为空,则宽度为0。
val width = placeables.maxOfOrNull { it.width } ?: 0
// 高度:所有已测量子组件的高度之和。
val height = placeables.sumOf { it.height }

最后填写自身的最终尺寸,并摆放所有子组件。

// 摆放子组件,并确定自身尺寸
// 调用 layout(width, height) 来设置当前自定义布局的最终尺寸
layout(width, height) {
    var yPosition = 0 // 下一个子组件在垂直方向上的摆放位置

    // 遍历所有已测量的子组件
    placeables.forEach { placeable ->
        // 计算当前子组件的 x 轴位置,使其水平居中
        val xPosition = (width - placeable.width) / 2
        
        // 放置子组件
        placeable.placeRelative(xPosition, yPosition)
        
        // 更新 yPosition,为下一个子组件的摆放做准备
        yPosition += placeable.height // 每个子组件所在的高度依次递增
    }
}

完整代码:

@Composable
fun CustomColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->

        // 测量所有子组件
        val placeables: List<Placeable> = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        // 计算自身尺寸 (基于子组件的尺寸)
        // 宽度:取所有子组件中的最大宽度
        val width = placeables.maxOfOrNull { it.width } ?: 0
        // 高度:所有子组件高度之和
        val height = placeables.sumOf { it.height }

        // 摆放子组件,并确定自身尺寸
        layout(width, height) {
            var yPosition = 0
            
            // 来确定每个子组件在其父布局中的位置,从上到下进行摆放
            placeables.forEach { placeable ->
                // 所有子组件水平居中对齐
                val xPosition = (width - placeable.width) / 2
                placeable.placeRelative(xPosition, yPosition)
                yPosition += placeable.height
            }
        }
    }
}

然后测试一下:

@Preview
@Composable
private fun CustomColumnPrev() {
    CustomColumn(Modifier.padding(12.dp)) {
        Text(text = "四十年来家国,三千里地山河。", modifier = Modifier.background(Color.Cyan))
        Text(text ="凤阁龙楼连霄汉,玉树琼枝作烟萝,几曾识干戈?", modifier = Modifier.background(Color.Green))
        Text(text ="一旦归为臣虏,沈腰潘鬓消磨。", modifier = Modifier.background(Color.LightGray))
        Text(text ="最是仓皇辞庙日,教坊犹奏别离歌,垂泪对宫娥。", modifier = Modifier.background(Color.DarkGray))
    }
}

预览效果:

image.png