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. 赠人玫瑰,手留余香。欢迎转发分享加关注,你的认可是我继续创作的精神源泉。
参考文献
- developer.android.google.cn/codelabs/je…
- 大海螺Utopia。《Android文字基线(Baseline)算法》. www.jianshu.com/u/79e66729b…
- Jetpack Compose 博物馆 - 自定义Layout. compose.net.cn/layout/cust…
- developer.android.google.cn/codelabs/je…