工作中碰到有一个需求是类似于 FlexColumn 的排列,但是项目中的 Compose 版本较低没有 Modifier.fillMaxColumnWidth(),于是考虑使用自定义 Layout 实现。
Compose UI 自定义布局实战
Layout
Compose UI 中其实和原生 View 系统类似,也可以实现自己的布局。原生 ViewGroup 需要实现 onMeasure,在里面测量每一个子 View,最后确定自身的尺寸。然后实现 onLayout 确定子 View 如何布局。
Compose 中我们使用 Layout 来实现,也是分两步:测量和布局。
1 2 3 4 5 6
| Layout( modifier = modifier, content = content, ) { measurables, constraints ->
}
|
我们需要对每个 measurable 调用 measure 传入对应的约束(Compose 父布局决定约束条件),然后调用 layout 方法决定每个 placeable 如何布局。
以一个自定义的 Column 为例,我们测量完成之后,一个个根据本身的高度向下排列即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Composable fun CustomColumn( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { Layout( modifier = modifier, content = content ) { measurables, constraints -> val placeables = measurables.map { it.measure(constraints) } var yPosition = 0 layout(constraints.maxWidth, constraints.maxHeight) { placeables.map { it.placeRelative(0, yPosition) yPosition += it.height } } } }
|
一切复杂的布局其实都是对 Layout 的两个步骤的扩展,只要对测量和布局过程熟悉就可以完成不同的布局的编写。
实战
需求是有一个 2~9 个子控件的布局,根据子控件的数量实现如下的排列:

FlowColumn 搭配使用 Modifier.fillMaxColumnWidth 和 Modifier.weight 将可以很轻松的实现需求的效果。
但由于项目依赖库的版本较低,无法使用 Modifier.fillMaxColumnWidth,且 FlowColumn 本身还被标记为 ExperimentalLayoutApi,于是该方案无法使用。
考虑到项目情况,该需求应该使用自定义 Layout 实现是更稳妥的方案。
需求分析
根据上述需求,我们可以发现该布局是一个以列为基本单位的布局。当子控件不足 7 个的时候,有两列;如果在 7 到 9 个之间,则有 3 列。
确定列数之后,可以取余确定第一列的 item 有多少个,如果余数为 0 表示第一列和其他列一样;否则余数为第一列的 item 个数。
每一列的宽度都是相等的,均分布局,但注意要减去分割线的宽度。
第一列如果是余数列,则以余数的数量均分高度(减去分割线宽度);否则所有列都是按行数均分高度(减去分割线宽度)。
方法定义
根据需求,我们有一个圆角的背景,子控件之间有分割线,所以我们添加对应的参数。
1 2 3 4 5 6 7 8 9 10
| @Composable fun CustomFlexLayout( modifier: Modifier = Modifier, dividerColor: Color = Color.Black, dividerWidth: Dp = 1.dp, shape: Shape = RoundedCornerShape(16.dp), content: @Composable () -> Unit, ) { }
|
分割线支持
Modifier 添加背景和 Shape,然后在布局的时候处理即可。
1 2 3
| modifier = modifier .background(color = dividerColor, shape = shape) .clip(shape),
|
测量和布局
预计算
在测量和布局之前,我们已经可以根据参数预先完成计算。然后我们就得出了 item 宽度,余数列(如果不能均分的时候的第一列)和普通列的 item 高度,以及每个 item 的位置(用一个 List 保存方便后续使用)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| val columns = columnStrategy(childrenSize)
val maxItemsInEachColumn = (childrenSize + columns - 1) / columns
val firstColumnItemsIfNotEvenly = childrenSize % maxItemsInEachColumn val dividerWidthPx = dividerWidth.roundToPx()
val isInFirstColumnIfNotEvenly: (Int) -> Boolean = { index -> firstColumnItemsIfNotEvenly > 0 && index < firstColumnItemsIfNotEvenly }
val normalColumnItemMaxHeight = (constraints.maxHeight - (dividerWidthPx * (maxItemsInEachColumn - 1).coerceAtLeast(0))) / maxItemsInEachColumn val firstColumnItemMaxHeightIfNotEvenly = if (firstColumnItemsIfNotEvenly == 0) 0 else (constraints.maxHeight - (dividerWidthPx * (firstColumnItemsIfNotEvenly - 1).coerceAtLeast(0))) / firstColumnItemsIfNotEvenly
val itemMaxWidth = (constraints.maxWidth - (dividerWidthPx * (columns - 1).coerceAtLeast(0))) / columns
val positionList = (0 until childrenSize).map { index -> val (rowIndex, columnIndex, itemMaxHeight) = if (isInFirstColumnIfNotEvenly(index)) { Triple(index, 0, firstColumnItemMaxHeightIfNotEvenly) } else { val adjustIndex = index - firstColumnItemsIfNotEvenly val row = if (maxItemsInEachColumn == 1) 0 else adjustIndex % maxItemsInEachColumn val column = adjustIndex / maxItemsInEachColumn + if (firstColumnItemsIfNotEvenly > 0) 1 else 0 Triple(row, column, normalColumnItemMaxHeight) } val positionX = itemMaxWidth * columnIndex + dividerWidthPx * columnIndex val positionY = itemMaxHeight * rowIndex + dividerWidthPx * rowIndex Pair(positionX, positionY) }
|
测量
需要注意的是 constraints 需要调用 copy 方法按索引生成新的约束,宽度所有 item 都是一样的,高度如果不均分情况下第一列和其他列会不一样。
1 2 3 4 5 6 7 8 9 10 11 12 13
| val placeables = measurables.mapIndexed { index, measureable -> val maxHeight = if (isInFirstColumnIfNotEvenly(index)) firstColumnItemMaxHeightIfNotEvenly else normalColumnItemMaxHeight measureable.measure( constraints.copy( minWidth = 0, maxWidth = itemMaxWidth, minHeight = 0, maxHeight = maxHeight ) ) }
|
布局
使用预计算好的 positionList 摆放子控件即可。
1 2 3 4 5 6 7
| layout(constraints.maxWidth, constraints.maxHeight) { placeables.mapIndexed { index, placeable -> val (positionX, positionY) = positionList[index] placeable.placeRelative(positionX, positionY) } }
|
To-do
以当前需求而言已经够用了,但是该控件还可以更进一步,比如
- 目前是列排列优先,可以增加行排列优先的支持
- 可以去掉子控件数量限制支持更多子控件数量
- 支持布局反向排列
总结
- 自定义布局的时候应该注意预先计算好一些参数,可以避免在测量和布局的时候在循环中计算,提高性能。
- 算法对写自定义布局是有帮助的,有空可以多刷一下 LeetCode 的算法题
- 善用语法糖可以让编码过程变得愉悦