上面是需求图
整个布局是可以垂直方向滑动的,同时内部需要用垂直方向的网格布局来显示图片。并且通过让宽度或间距平分剩余空间,来实现对齐效果。如果丢个FlowRow代替Grid,是不能实现对齐的,基本会头重脚轻
同向可滑动嵌套的隐患
众所周知,同向的可滑动嵌套可能会导致性能问题。比如A布局允许垂直方向滑动,B是A布局中的一个RecyclerView,高度为wrapContent,由于B的父布局A可以垂直滑动,A对B的高度不做限制,B会一口气把所有的元素都加载出来,导致RecyclerView的延迟加载和回收逻辑都失效,如果数据太多的话会导致性能问题。如果是分页列表的话,那么它还会一直处于加载到底部-》触发加载下一页-》加载到底部的循环中,导致从性能问题进化到业务问题。
Compose中为了防止这个情况,对同向的滑动进行了限制,在大多数的同向可滑动布局嵌套时会直接抛出异常(除非child在对应方向上设置了具体的尺寸,比如B在A里面,B设置了Modifier.height(30.dp),这样可以正常运行)。
有一些场景,我们确定在特定的业务场景下,集合元素不会很多,而且UI就要求用高度自适应的网格布局来显示内容。在使用View时我们有很多方法,有直接的网格布局,也可以头硬上RecyclerView(虽然可能会导致问题,但View体系中并没有禁止开发者这么做,后果自负就是了)。
但是在Compose中,由于只提供了LazyXXXGrid,并没有那种不需要滑动,高度自适应,可以直接在滑动布局内部使用的Grid,有的时候会导致困扰——我不想把整个滑动界面改成LazyXXXGrid,我也确定Grid中的元素不会那么多,甚至只有几个,绝对不影响性能。
这个时候我们还只能自己实现一个简单的Grid了,直接上代码。
代码
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* 由于Compose只提供了懒加载的LazyVerticalGrid,又不允许同向的嵌套滑动,会直接抛出异常,所以需要我们自己实现Grid布局(很多时候我们知道嵌套滑动会导致懒加载失效,内容全部加载等问题,但是我们也确定一些使用场景里Grid的内容不会过多,就是想要使用Grid)。
*
* 用类似LazyVerticalGrid的布局效果:会在遵守[minItemSize]的前提下尽可能的在每一行放更多的项,并让他们平分剩余空间。
*
* 由于是简单实现,所以布局中的每一项会设置为正方形,高度向宽度对齐
*
* 比如总宽度为100,minItemSize为30,那么最终会有3列,每列宽度为100/3(这个例子中没有考虑[horizontalSpace])
* @param minItemSize 子项的最小尺寸。在布局的最大宽度小于这个尺寸时,会让子项尺寸为最大宽度;否则子项的宽度至少为这个尺寸
* @param horizontalSpace 水平方向的间距,只有一列时间距不会生效
* @param verticalSpace 垂直方向的间距,只有一行时间距不会生效
* @param content 直接往里丢内容即可,参考[Row],[Column]等布局的content参数
*/
@Composable
fun EasyGrid(
minItemSize: Dp,
modifier: Modifier = Modifier,
horizontalSpace: Dp = 0.dp,
verticalSpace: Dp = 0.dp,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measurables: List<Measurable>, constraints: Constraints ->
val horizontalSpaceValue = horizontalSpace.toPx()
val verticalSpaceValue = verticalSpace.toPx()
val totalWidth = constraints.maxWidth
val minItemWidth = minItemSize.toPx()
// 用于记录最终的列数,至少有一列
var numItems = 1
// 剩余多少可用宽度
var remainWidth = totalWidth.toFloat() - minItemWidth
// 计算可以摆放的项的最大数量,同时考虑间隔
while (remainWidth >= minItemWidth + horizontalSpaceValue) {
remainWidth -= (minItemWidth + horizontalSpaceValue)
numItems++
}
// 此时numItems已经是计算完的列数,再来计算平均宽度
val finalItemWidth = when {
// 只有一列时,子项宽度就和布局的总宽度一致
numItems == 1 -> {
totalWidth
}
else -> {
((totalWidth - horizontalSpaceValue * (numItems - 1)) / numItems).toInt()
}
}
// 计算一下总共有多少行,然后计算总高度
val totalHeight = when (measurables.size % numItems != 0) {
true -> {
val lineCount = (measurables.size / numItems) + 1
lineCount * finalItemWidth + (lineCount - 1) * verticalSpaceValue
}
else -> {
val lineCount = measurables.size / numItems
lineCount * finalItemWidth + (lineCount - 1) * verticalSpaceValue
}
}
// 宽度就是最大宽度,高度为总高度,开始布局
var offsetX = 0
var offsetY = 0
layout(width = totalWidth, height = totalHeight.toInt()) {
measurables.forEachIndexed { index, measurable ->
// 直接强制设置子项的宽度高度都与计算出来的平均宽度一致
val placeable = measurable.measure(
Constraints(
minWidth = finalItemWidth,
maxWidth = finalItemWidth,
minHeight = finalItemWidth,
maxHeight = finalItemWidth
)
)
// 摆放
placeable.place(offsetX, offsetY)
if ((index + 1) % numItems == 0) {
// 此项是一行中的最后一列,开新的一行
offsetY += (finalItemWidth + verticalSpaceValue).toInt()
offsetX = 0
} else {
// 继续下一列
offsetX += (finalItemWidth + horizontalSpaceValue).toInt()
}
}
}
}
}
使用案例
不放完整的使用代码了,就展示一下content里面的结构,怎么放元素