在上一篇中,我们不仅了解了 Compose 中的 Column、Row、Box 等几种常见的布局方式 还学习了 CompositionLocal 类在 Compose 中进行传值的方法;还有可快速搭建 App 结构的 Scaffold 脚手架组件,顺便学习了 Surface、Modifier 的一些使用,还有 ConstraintLayout 在Compose 中的使用方法。虽然官方提供了这么多 Compose 组件,但在实际需求开发中,定制化组件仍然必不可少。
在传统的 View 体系中,系统为开发者提供了许多可以直接使用的组件 View,比如:TextView、ImageView、RelativeLayout等。我们也可以通过自定义 View 来创建一些系统没有提供给我们的、具有特殊功能的 View。Compose 当然也不甘落后,在 Compose 中我们可以使用 Layout 组件来自定义我们自己的 Composable 组件。实际上,所有类似于 Column、Row 等组件底层都是用 Layout 进行扩展实现的。
在 View 体系中,自定义 View 最为常见的两种情况是:1)继承已有 View 进行功能扩展,例如继承 TextView 或直接继承 View 进行改写;2)继承 ViewGroup,并重写父类的 onMeasure 和 onLayout 方法。而在 Compose 中我们只需要简单地使用 Layout 组件自定义就可以了。
在开始之前,我们需要先了解一下 Layout Composable 组件的一些基础知识。
1. Compose 自定义 Layout 的基本原则
在 Compose 中,一个 Composable 方法被执行时,会被添加到 UI 树中,然后会被渲染展示在屏幕上。这个 Composable 方法我们可以看成是一个 View 系统中的布局,在 Compose 中称为 Layout。每个 Layout 都有一个 parent Layout 和 0 个或多个 children,这跟 View 体系很像。当然,这个 Layout 自身含有在它的 parent Layout 中的位置信息,包括位置坐标(x, y)
和它的尺寸大小 width
和height
。
Layout 中的 children Layout 子元素会被调用去测量它们自身的大小,同时需要满足规定的 Constraints 约束。这些 Constraints 约束限制了 width
和height
的最大值和最小值。当 Layout 把自己的 children Layout 测量完成之后,它自己的尺寸才会确定下来,又是递归。。。一旦一个 Layout 元素完成自身的测量,它就可以将自己的 children 根据 Constraints 约束在自己的空间中进行摆放了。是不是跟 View 体系一样?先测量后摆放。
OK,最重要的来了!Compose UI 不允许多次测量。 Layout 元素为了尝试不同的测量设置,它不能多次测量其任何子元素。单次测量(Single-pass measurement)当然会提升渲染效率,尤其是在 Compose 处理深度较大的 UI 树时。如果一个 Layout 元素需要测量两次它的所有子元素,子元素中的子元素就会被测量四次,以此类推,测量的次数就会随着布局深度成指数级增长!其实 View 体系就是这样的,所以在 View 体系中开发一定要减少布局的层数!不然在需要重复测量的情况下,渲染效率将会及其低下。所以 Compose 中才做了不允许多次测量的限制,然而,在有些场景下,我们又是需要获取到子元素多次测量并获取信息的。对于这些情况,还是有方法做到多次测量的,限于篇幅原因,后面有空再说~
Compose 中自定义一个控件(官方称之为 Layout)也有两种情况:
- 自定义 Layout 没有其他子元素,就只是它自己本身,类似于 View 体系中的 “自定义View”;
- 自定义 Layout 有子元素,需要考虑子元素的摆放位置,类似于 View 体系中的 “自定义ViewGroup”。
我们先来看第一种情况。
2. Compose 自定义一个 “View”
Compose 中的自定义 Layout 跟 View 体系是很不同的。我们需要自定义的 Layout 居然就是自定义一个 Modifier 属性!就是去自己实现 Modifier 中 Layout 方法,去实现如何测量以及放置它自己本身即可。一个常见的自定义 Layout Modifier 的结构代码如下:
// code 1
fun Modifier.customLayoutModifier(...) { // 可以自定义一些属性
Modifier.layout { measurable, constraints ->
... // 在这里需要自己实现 测量 和 放置的方法
}
}
可以看出来,关键就是 Modifier.layout 方法,它有两个 lambda 表达式:
-
measurable
:用于子元素的测量和位置放置的; -
constraints
:用于约束子元素 width 和 height 的最大值和最小值。
举个简单的栗子进行说明。一个普通的 Text 组件只能调整文案的边缘离 Text 组件上下左右四边缘的距离,例如图1所示。这个 Text 只能设置四周的 padding 值,上下我设置的 15dp,左右设置的 30dp。
如果我想控制文案的底部 baseline 离 Text 上边距的距离呢?啥是底部 baseline?这就需要了解一下 Android 在绘制文案时的算法了。
从图 2 可以看出,Android 绘制文案时,baseline 决定了文案主体的底部位置。Compose 中的 Text 只能通过 Modifier.padding 设置 leading 离 Text 组件顶部的距离。而这里我们自定义的 Layout 需要满足可设置 Baseline 离 Text 顶部的距离。即下图图 3 中上方的效果,怎么做呢?
首先当然就是测量啦,记住 Layout 只能测量它的子元素一次。在 code1 中调用 measure 方法,就可以测量了:
// code 2
fun Modifier.firstBaselineToTop( // firstBaselineToTop 就是你自定义的 modifier 的方法名
firstBaselineToTop: Dp // 自定义 modifier 方法中的参数,这里就是一个
) = this.then(
layout { measurable, constraints -> // 调用 layout 方法去测量和放置子元素组件
val placeable = measurable.measure(constraints) // 首先是测量
...
}
)
当调用 measurable 的 measure 方法后,就会返回一个 Placeable 对象。在这里,我们可以将 layout 中的 constraints 约束条件传递给 measure 方法,或者传入我们自定义的约束条件的 lambda。因为在这个场景下我们不需要再去对测量进行任何的限制,所以直接传入 layout 中给的 constraints 即可。总之,这一步就是为了得到这个 Placeable 对象,拿到这个之后就可以在后面调用 Placeable 对象的 placeRelative 方法对子元素进行位置的摆放了!
OK,现在已经对 Composable 组件进行了测量,然后我们就可以调用 layout(width, height) 方法去根据测量的尺寸来放置内容。width 不用求,直接用测量得来的 width 就行,关键就是如何求出传入 layout 方法的 height 值,看代码再来说吧:
// code 3
fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = this.then(
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// 检查这个 Composable 组件是否存在 FirstBaseline
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
// 存在的情况下,获取 FirstBaseline 离 Composable 组件顶部的距离
val firstBaseline = placeable[FirstBaseline]
// 计算 Y 轴方向上 Composable 组件的放置位置
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
// 计算得出此 Composable 组件真正的 height 值
val height = placeable.height + placeableY
layout(placeable.width, height) {
...
}
}
)
说实话最初看到这段代码也是懵逼了好久。。。
首先 check 方法类似于一个 assert 断言,如果里面的结果是 false 则会抛出一个 IllegalStateException 异常。这里是检查下被我们自定义的 Modifier 修饰的 Composable 组件是否存在 FirstBaseline 属性,Text 组件里是存在 baseline 的,如果不存在当然就不能用我们自定义的这个 firstBaselineToTop Modifier了。
存在的情况下,再去获取这个 Baseline 与 此组件顶部的距离,也就是图4 中 c 的长度。图中蓝色框代表的是普通的 Text 组件所占的空间位置;黑色框代表的是屏幕边缘;红色虚线代表的是 Text 中的 Baseline。a 表示的就是我们自定义的 Modifier.firstBaselineToTop 方法的 firstBaselintToTop 参数。我们的目标就是可以根据传入的 firstBaselintToTop 参数计算出 Text 组件在 Y 轴上的摆放位置,以及真正的 width 和 height 值大小。
之前在 layout 方法中调用了 measurable 的 measure 方法测量的是普通 Text 组件的宽高,即图4 中蓝色框的宽高,而我们自定义的 Layout 的宽高则是图中用橙色和绿色标注的宽高尺寸。width 直接由 Placeable 对象就可获得(placeable.width),而高度由示意图可以得出计算方法:height = placeable.height + d
,即普通 Text 的高度再加上 d,d = a - c,即 d = firstBaselintToTop - baseline
。所以,d 就是 placeableY 参数。终于看懂 code 3 了,原来就是为了算出自定义 Layout 的 width 和 height,然后通过 layout 方法进行设置啊!
接下来就是位置的放置了。调用 Placeable 对象的 placeRelative 方法即可:
// code 4
fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = this.then(
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
val height = placeable.height + placeableY
layout(placeable.width, height) {
placeable.placeRelative(0, placeableY)
}
}
)
注意,自定义 Layout 必须调用 placeRelative 方法,否则该自定义 Layout 将不可见。 placeRelative 方法会根据当前的 layoutDirection 布局方向对自定义 Layout 自动进行位置调整。在这里我们自定义的 Layout 摆放比较简单,就是 Y 轴上有个偏移量,X 轴上没有偏移,看图2 也可直观得知。
那么如何使用呢?想必你们也猜到了,就跟之前使用其他 Modifier 方法修饰 Text 或其他 Composable 组件一样使用就好:
// code 5
@Composable
fun CustomLayoutDemo() {
Row {
Text(
text = "我是栗子1",
modifier = Modifier.firstBaselineToTop(40.dp),
fontSize = 20.sp
)
Spacer(modifier = Modifier.width(20.dp))
Text(
text = "我是栗子2",
modifier = Modifier.firstBaselineToTop(40.dp),
fontSize = 15.sp
)
Spacer(modifier = Modifier.width(20.dp))
Text(
text = "我是栗子3",
modifier = Modifier.firstBaselineToTop(40.dp),
fontSize = 30.sp
)
}
}
在 code 5 中分别展示了 3 个 Text,都使用了我们自定义的 Modifier 修饰符 firstBaselineToTop,且设置的参数都是 40dp,不同的是字号。从图 5 的显示效果来看,达到了我们想要的自定义 Layout 的效果,即虽然字号大小不同,但是每个 Text 中文案的 Baseline 离自定义 Layout 的顶部距离是一样的。
3. 自定义一个 “ViewGroup”
说完了 Compose 自定义“View” 的方法,当然也就少不了自定义“ViewGroup” 了。其实,Compose 中的 Row、Column 组件都是使用 Layout 方法实现的,它也是 Compose 用来自定义一个 “ViewGroup” 的核心方法。我们可以通过 Layout 组件手动地对它其中的子元素进行测量和摆放,一个自定义 “ViewGroup”的 Layout 代码结构通常如下代码所示:
// code 6
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
// 此处可添加自定义的参数
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
){ measurable, constraints ->
// 对 children 进行测量和放置
···
}
}
对于一个自定义 Layout 来说,,最少需要三个参数:
- modifier:由外部传入的修饰符,用来修饰我们自定义的这个 Layout 组件的一些属性或 Constraints;
- content:我们自定义的这个 Layout 组件中所包含的子元素 children;
- measurePolicy:熟悉 Kotlin 语法的同学们会知道,code 6 中 Layout 后跟着的 lambda 表达式其实也是 Layout 的一个参数,从字面意思上也可知道,这个是为了对 children 进行测量和摆放操作的。默认场景下只实现 measure 方法即可,当我们想让我们自定义的 Layout 组件适配 Intrinsics (官方称之为 固有特性测量)时,就需要重写 minIntrinsicWidth、minIntrinsicHeight、maxIntrinsicWidth、maxIntrinsicHeight 方法。篇幅原因以后再说哈~
这里我们用 Layout 组件自定义一个基本的简单的 Column 组件,用于竖直方向上摆放子元素,我们取名为 MyOwnColumn。如之前所述的,我们第一件事就是测量 children,并且只能测量一次。与之前的自定义“View”不同的是,这里需要测量的不是它本身的尺寸,而是测量它其中包含的所有 children 的尺寸:
// code 7
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
// 此处可添加自定义的参数
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
){ measurables, constraints ->
// 对 children 进行测量和放置
val placeables = measurables.map { measurable ->
// 测量每个 child 的尺寸
measurable.measure(constraints)
}
...
}
}
可以看出,在 map 里每个 child 都调用 measure 方法进行了测量,并且与之前一样,我们无需再针对测量进行限制,所以直接传入 Layout 中的 constraints 即可。到这里,我们已经测量了所有的 children 子元素。
在设置这些 children 的位置之前,我们还需要根据测量的 children 尺寸来计算得出我们自定义的 MyOwnColumn 组件自身的宽高了。下面代码是尽最大可能地设置我们自定义的 MyOwnColumn 的 Layout 尺寸:
// code 8
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
// 此处可添加自定义的参数
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
){ measurables, constraints ->
// 对 children 进行测量和放置
val placeables = measurables.map { measurable ->
// 测量每个 child 的尺寸
measurable.measure(constraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
// 摆放 children
...
}
}
}
最后就可以对 children 进行摆放了。与上述的自定义“View”相同,我们也是调用placeable.placeRelative(x,y)
来放置位置。因为是自定义一个 Column,需要竖直方向上一个个进行摆放,所以每个 child 水平方向上 x 肯定从最左边开始,设置为 0 。而竖直方向上需要一个变量记录下一个 child 在竖直方向上的位置值。详细代码如下:
// code 9
@Composable
fun MyOwnColumn(
modifier: Modifier = Modifier,
// 此处可添加自定义的参数
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
){ measurables, constraints ->
// 对 children 进行测量和放置
val placeables = measurables.map { measurable ->
// 测量每个 child 的尺寸
measurable.measure(constraints)
}
var yPosition = 0 // 记录下一个元素竖直方向上的位置
layout(constraints.maxWidth, constraints.maxHeight) {
// 摆放 children
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, yPosition)
yPosition += placeable.height
}
}
}
}
注意一下我们自定义的这个 Column 的宽高设置的是尽最大可能撑满父布局:layout(constraints.maxWidth, constraints.maxHeight)
,所以跟官方的 Column 是有很大的不同的。这里只是为了说明 Compose 中自定义一个“ViewGroup”的方法流程。
MyOwnColumn 在使用上与 Column 一致,只是占用父布局空间的策略不一样。官方的 Column 布局默认情况下宽高是尽可能小的占用父布局,类似于 wrap_content;而 MyOwnColumn 是尽可能大的占用父布局,类似于 match_parent。下图图6 也可以清楚地看到效果。
// code 10
@Composable
fun MyOwnColumnDemo() {
MyOwnColumn(Modifier.padding(20.dp)) {
Text("我是栗子1")
Text("我是栗子2")
Text("我是栗子3")
}
}
对比一下 Compose 中的自定义 Layout 的两种方式,一种是针对某个组件进行的功能扩展,类似于 View 体系中对某个已有的 View 或直接继承 View 进行的自定义,它其实是自定义一个 Modifier 方法;另一种是针对某个容器组件的自定义,类似于 View 体系中对某个已有的 ViewGroup 或直接继承 ViewGroup 进行自定义,它其实就是一个 Layout 组件,是布局的主要核心组件。接下来让我们看看更加复杂的自定义 Layout。
4. 自定义复杂的 Layout
OK,了解了 Compose 自定义 Layout 的基本方法步骤,让我们看看一个稍微复杂的栗子。假如需要实现一个横向滑动的瀑布流布局,例如下图中间部分所示:
可以设置展示成多少行,这里是展示成 3 行,我们只需要传入所有的子元素即可。现有的官方 Compose 组件中没有这种功能的组件,这就需要定制化了。先按照之前的模板代码构建一下框架:
// code 11
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3, // 自定义的参数,控制展示的行数,默认为 3行
content: @Composable () -> Unit
){
Layout( // 主要还是这个 Layout 方法
modifier = modifier,
content = content
) { measurables, constraints ->
// 测量和位置摆放逻辑
}
}
接下来还是那个流程:1)测量所有子元素尺寸;2)计算自定义 Layout 的尺寸;3)摆放子元素。这里只展示 Layout 方法中的代码:
// code 12
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 用于记录每一行的宽度信息
val rowWidths = IntArray(rows){0}
// 用于记录每一行的高度信息
val rowHeights = IntArray(rows){0}
val placeables = measurables.mapIndexed { index, measurable ->
// 标准流程:测量每个 child 尺寸,获得 placeable
val placeable = measurable.measure(constraints)
// 根据序号给每个 child 分组,记录每一组的宽高信息
val row = index % rows
rowWidths[row] += placeable.width
rowHeights[row] = max(rowHeights[row], placeable.height)
placeable // 测量完了要记得返回 placeable 对象
}
...
}
接下来,就是计算自定义 Layout 自身的尺寸了。通过上面的操作,我们已经得知每行 children 的最大高度,那么所有行高度相加就可以得到自定义 Layout 的高度了;而所有行中宽度最大值就是自定义 Layout 的宽度了。此外,我们还得到了每一行在 Y 轴上的位置了。相关的代码如下:
// code 13
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
...
// 自定义 Layout 的宽度取所有行中宽度最大值
val width = rowWidths.maxOrNull()
?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth))
?: constraints.minWidth
// 自定义 Layout 的高度当然为所有行高度之和
val height = rowHeights.sumOf { it }
.coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
// 计算出每一行的元素在 Y轴 上的摆放位置
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i - 1] + rowHeights[i - 1]
}
// 设置 自定义 Layout 的宽高
layout(width, height) {
// 摆放每个 child
...
}
}
咦?是不是又和你想象中的代码不太一样?在求宽度 width 时,它还使用了 coerceIn 方法对 width 进行了限制,限制 width 在 constraints 约束的最小值和最大值之间,如果超出了则会被设置成最小值或最大值。height 也是如此。然后还是调用的 layout 方法来设置我们自定义 Layout 的宽高。
最后,就是调用 placeable.placeRelative(x, y)
方法将我们的 children 摆放到屏幕上即可。当然,还是需要借助变量存储 X 轴 上的位置信息的。具体代码如下:
// code 14
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
...
// 计算出每一行的元素在 Y轴 上的摆放位置
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i - 1] + rowHeights[i - 1]
}
// 设置 自定义 Layout 的宽高
layout(width, height) {
// 摆放每个 child
val rowX = IntArray(rows) { 0 } // child 在 X 轴的位置
placeables.forEachIndexed { index, placeable ->
val row = index % rows
placeable.placeRelative(
rowX[row],
rowY[row]
)
rowX[row] += placeable.width
}
}
}
代码逻辑比较简单,不再多做什么解释。综上,完整的这个自定义 Layout 的代码如下:
// code 15
// 横向瀑布流自定义 layout
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
rows: Int = 3, // 自定义的参数,控制展示的行数,默认为 3行
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 用于记录每一横行的宽度信息
val rowWidths = IntArray(rows) { 0 }
// 用于记录每一横行的高度信息
val rowHeights = IntArray(rows) { 0 }
// 测量每个 child 尺寸,获得每个 child 的 Placeable 对象
val placeables = measurables.mapIndexed { index, measurable ->
// 标准流程:测量每个 child 尺寸,获得 placeable
val placeable = measurable.measure(constraints)
// 根据序号给每个 child 分组,记录每一个横行的宽高信息
val row = index % rows
rowWidths[row] += placeable.width
rowHeights[row] = max(rowHeights[row], placeable.height)
placeable // 这句别忘了,返回每个 child 的placeable
}
// 自定义 Layout 的宽度取所有行中宽度最大值
val width = rowWidths.maxOrNull()
?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth))
?: constraints.minWidth
// 自定义 Layout 的高度当然为所有行高度之和
val height = rowHeights.sumOf { it }
.coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
// 计算出每一行的元素在 Y轴 上的摆放位置
val rowY = IntArray(rows) { 0 }
for (i in 1 until rows) {
rowY[i] = rowY[i - 1] + rowHeights[i - 1]
}
// 设置 自定义 Layout 的宽高
layout(width, height) {
// 摆放每个 child
val rowX = IntArray(rows) { 0 } // child 在 X 轴的位置
placeables.forEachIndexed { index, placeable ->
val row = index % rows
placeable.placeRelative(
rowX[row],
rowY[row]
)
rowX[row] += placeable.width
}
}
}
}
OK,再写一个小的组件作为 children 子元素,用来显示,具体代码如下:
// code 16
@Composable
fun Chip(
modifier: Modifier = Modifier, text: String
) {
Card(
modifier = modifier,
border = BorderStroke(color = Color.Magenta, width = Dp.Hairline),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(16.dp, 16.dp)
.background(color = MaterialTheme.colors.secondary)
)
Spacer(Modifier.width(4.dp))
Text(text = text)
}
}
}
还有一点需要注意的是,我们自定义的 Layout StaggeredGrid 的宽度是会超出屏幕的,所以在实际使用中,还得添加一个 Modifier.horizonalScroll 用于水平方向上滑动,这样才用着舒服~ 实际使用的代码样例如下:
// code 17
val topics = listOf(
"Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
"Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
"Religion", "Social sciences", "Technology", "TV", "Writing"
)
Row(modifier = Modifier.horizontalScroll(rememberScrollState())){
StaggeredGrid() {
for (topic in topics) {
Chip(modifier = Modifier.padding(8.dp),text = topic)
}
}
}
当然,还支持自己设置需要展示成几行的样式,这里默认值为 3行。
总结一下,在 Compose 中自定义 Layout 的基本流程其实跟 View 体系中自定义 View 的一样,其中最大的不同就是在测量的步骤,Compose 为提高效率不允许多次进行测量。而且 Compose 的自定义 Layout 的两种情况也可以对应到 View 体系中的两个情况,但可以看出,Compose 都是在 Layout 组件中进行的改写与编程,可以让开发者更加聚焦在具体的代码逻辑上,这也是 Compose 自定义 Layout 的优势所在。那么,Compose 的自定义“View”,你学会了么?
ps. 赠人玫瑰,手留余香。欢迎转发分享加关注,你的认可是我继续创作的精神源泉。
参考文献
- https://developer.android.google.cn/codelabs/jetpack-compose-layouts?continue=https%3A%2F%2Fdeveloper.android.google.cn%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#6
- 大海螺Utopia。《Android文字基线(Baseline)算法》. https://www.jianshu.com/u/79e66729b5ec
- Jetpack Compose 博物馆 - 自定义Layout. https://compose.net.cn/layout/custom_layout/
- https://developer.android.google.cn/codelabs/jetpack-compose-layouts?continue=https%3A%2F%2Fdeveloper.android.google.cn%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#7