在之前的一篇文章中,我们介绍了 Compose 的 FlexBox,作为响应式布局的一个 API,它提供的主要能力就是——自动换行。不要小看这个能力,我称之为响应式布局的核心功能。
而新增的 Grid API,补上了 Compose 在二维结构化布局上的一块短板,提供了一种在多列或多行布局中显示子项的能力。
废话少说,我们直接开始。
开胃菜
Grid 既不同于面向大规模数据展示的惰性网格(各种 LazyXXX),也不同于通过 Row 与 Column 手工拼出来的二维结构。
Grid 让开发者可以在多列或多行布局中显示子项,这些布局会根据可用的容器大小进行调整。
上面这个布局,你只需要一个 Grid 就可以搞定,并不需要自己通过 Row 或者 Column 拼凑。
略有不同
Compose 已经提供了一些相似组件,例如 LazyVerticalGrid。
这些组件主要用于可视化大量、同质化的数据集,例如在视频流应用中展示内容目录,这些目录信息基本是统一的(包含封面图、标题、类型等信息),且量大。
LazyVerticalGrid 组件并不是为屏幕结构布局或复杂组件设计的。
对于二维布局,现有方案通常是通过组合多个 Row 和 Column 来实现。不过,这种方式存在一些缺点,例如层级较深,以及在适配性上更难处理。
你可以想象一下在传统的 XML 中, LinearLayout 和 ConstraintLayout 解决的问题,ConstraintLayout 可以在不花大力气的情况下让布局的嵌套层次减少很多层,甚至可以只有一层!
下表概括了各类 API 分别适合什么布局场景:
| 组件 | 用途 |
|---|---|
LazyXXXGrid | 需要惰性加载的大型、同质化数据集可视化。 |
Row, Column, FlexBox | 一维布局 |
Grid | 二维布局 |
换句话说,如果你要做的是“页面结构布局”,而不是“海量列表渲染”,那么 Grid 是更贴切的选择。
搞点薯条
开始之前,我们先熟悉一些术语,有助于理解 Grid 的工作方式。
Grid line
网格由横向和纵向的线组成。
如果你的网格有三行,那么它会有四条水平线,其中也包括最后一行之后的那条线。
在下图中,每一条虚线都代表一条 grid line。
大家用过 Excel 吧?其实是一样的。
Grid track
grid track 是两条 grid line 之间的空间。
row track 位于两条水平线之间,column track 位于两条垂直线之间。
要定义这些 track 的大小,需要在创建 Grid 时为它们指定尺寸(记住这句话,后面会有相关的 API 与这个相关)。
Grid cell
grid cell 是 row track 与 column track 相交后形成的单元格。
Grid area
grid area 由多个 grid cell 组成。
你可以跨越多个 track,从而定义出一个 grid area。
Grid gap
grid gap 是各个 grid track 之间的沟槽间距。
你不能把 UI 元素直接放进 gap 中,但可以让 UI 元素跨越它。
砍柴磨刀
现在,我们讲讲如何实现基础的 Grid 布局。
可以参考上一篇文章的配置,使用最新的 foundation-layout。
[versions]
compose = "1.11.0-beta02"
[libraries]
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" }
对了,别忘了在顶层文件中声明这个 @file:OptIn(ExperimentalGridApi::class),屏蔽编译器的实验性 API 提示。
先跑起来
下面的示例创建了一个基础的 2x3 网格,其中列和行都使用固定的 100.dp 尺寸。
这里,我们依然采用上一篇文章提到的几个五颜六色的卡片,这里不再赘述,你知道效果就行。
Grid(
config = {
repeat(2) {
column(100.dp)
}
repeat(3) {
row(100.dp)
}
}
) {
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox()
OrangeRoundedBox()
PinkRoundedBox()
RedRoundedBox()
}
如果一切顺利的话,你会得到下面这个漂亮的网格:
如果你想实现更高级的网格能力,可以继续看下面的容器属性和子项属性部分。
定义容器
你可以通过定义 Grid 容器配置来创建能够响应不同屏幕尺寸和内容类型的灵活布局。
这些配置包括:定义网格、在网格中放置条目、管理 track 尺寸,以及设置 gap。
定义网格
一个网格由列和行组成。
Grid 提供了 config 参数,它接收一个 lambda,用来在 GridConfigurationScope 中定义列和行。
下面的示例定义了一个三行两列的网格,并且每个 track 都使用 Dp 指定固定尺寸。
Grid(
config = {
repeat(2) {
column(160.dp)
}
repeat(3) {
row(90.dp)
}
}
) {
}
放置子项
Grid 会读取 content lambda 中的 UI 元素,并将它们放入各个 grid cell。
无论你是否显式定义了行和列,网格都会对条目进行布局。
默认情况下,Grid 会尝试把一个 UI 元素放到当前行中可用的 grid cell 里。
如果当前行放不下,它就会把该元素放到下一行中可用的 grid cell 里。如果已经没有空单元格,Grid 会创建新的一行。
下面的示例中,网格有六个 grid cell,并在每个单元格中放入一张卡片。
每个 grid cell 的大小都是 160dp x 90dp,因此整个网格总大小为 320dp x 270dp。
Grid(
config = {
repeat(2) {
column(160.dp)
}
repeat(3) {
row(90.dp)
}
}
) {
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox()
OrangeRoundedBox()
PinkRoundedBox()
RedRoundedBox()
}
如果你想把这种默认行为改为“按列填充”,可以把 flow 属性设置为 GridFlow.Column。
Grid(
config = {
repeat(2) {
column(160.dp)
}
repeat(3) {
row(90.dp)
}
gap(8.dp)
flow = GridFlow.Column
},
) {
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox()
OrangeRoundedBox()
PinkRoundedBox()
RedRoundedBox()
}
如果你没一眼看出区别,仔细看卡片的颜色!
这个 flow 属性其实就是定义布局方向,你可以参考上一篇文章中的 FlexBox direction 参数,他们的意义是类似的。
管理 track 尺寸
行和列合起来统称为 grid track(即整行,整列都可以看作一个 track/轨道)。
你可以使用以下几种方式指定 grid track 的大小:
- 固定值(
Dp):分配一个明确尺寸,例如column(180.dp)。 - 百分比(
Float):按总可用空间的比例分配,范围从0.0f到1.0f,例如row(0.5f)表示50%。 - 弹性值(
Fr):在固定值和百分比 track 计算完成后,按比例分配剩余空间。例如两行分别设置为1.fr与3.fr时,后者会得到剩余高度的 75%。 - 固有尺寸:根据其内部内容来决定 track 大小。
下面的示例使用不同的 track 尺寸选项来定义各行高度。
Grid(
config = {
column(1f)
row(100.dp)
row(0.2f)
row(1.fr)
row(GridTrackSize.Auto)
},
modifier = Modifier.height(480.dp)
) {
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox()
PinkRoundedBox(modifier = Modifier.height(40.dp))
}
我不得不在这里解释一下为什么会这样布局:
column(1f):单列,那就是这个布局当做一整行来处理了;row(100.dp):红色网格固定高度为100dp;row(0.2f):蓝色网格需要占用整个大小的 20%;row(GridTrackSize.Auto):自己决定,那么粉红色的网格就是自定义高度——40dp;row(1.fr):绿色网格填充剩余高度。实际上这里无论给多少值,都会占满剩余区域,因为没有别的fr定义了。
自适应
当你希望布局根据内容自适应,而不是强行塞进固定容器时,可以对 Grid 使用 intrinsic sizing。
grid track 的尺寸可以通过以下取值决定:
GridTrackSize.MaxContent:使用内容的最大固有尺寸。GridTrackSize.MinContent:使用内容的最小固有尺寸。GridTrackSize.Auto:使用一种基于可用空间自适应的灵活尺寸。它默认表现得像MaxContent,但在需要时会收缩并换行,以适应父容器。
下面的示例将两段文本并排放置。
第一列的宽度由显示该文本所需的最小宽度决定,第二列的宽度则取决于文本所需的最大宽度。
Grid(
config = {
column(GridTrackSize.MinContent)
column(GridTrackSize.MaxContent)
row(1.0f)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras imperdiet.")
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras imperdiet.")
}
为什么第一列的 Text 会这么显示呢?
因为默认情况下,一个 Text 的最小固有尺寸,就是里面最长单词的尺寸。
设置行列间距
当 grid track 的尺寸确定之后,你可以通过调整 grid gap 来进一步优化各个 track 之间的间距。
你可以使用 columnGap() 指定列间距,使用 rowGap() 指定行间距。
下面的示例中,每一行之间有 16dp 的间距,每一列之间有 8dp 的间距。
Grid(
config = {
repeat(2) {
column(160.dp)
}
repeat(3) {
row(90.dp)
}
rowGap(16.dp)
columnGap(8.dp)
}
) {
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox()
OrangeRoundedBox()
PinkRoundedBox()
RedRoundedBox()
}
你也可以使用便捷函数 gap(),一次性定义相同的行列间距。
下面的代码为网格添加了 8dp 的 gap。
Grid(
config = {
repeat(2) {
column(160.dp)
}
repeat(3) {
row(90.dp)
}
gap(8.dp)
}
) {
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox()
OrangeRoundedBox()
PinkRoundedBox()
RedRoundedBox()
}
控制子项
Grid 的 config 定义的是整体结构,而子项在这个结构中的位置、跨越范围和对齐方式,则通过 gridItem modifier 来控制。
位置
你可以通过 row 和 column 参数,把一个条目放进指定的 track 或 cell。row 与 column 参数表示条目所在的行 track 与列 track 的索引。
track 索引从 1 开始计算(很遗憾这里没有使用 0 作为开始)。
如果只指定 row 或 column 其中一个,那么该条目会被放进这个 track 中下一个可用位置。如果两个都指定,则会被放到对应的那个 cell 中。
你可以使用正整数来表示从起始位置开始的 track 索引。例如,如果要把一个条目放到第一行第一列,可以写成 gridItem(row = 1, column = 1)。
你也可以使用负整数来表示从末尾开始倒数的 track。例如,要把一个条目放到倒数第二行和倒数第二列,可以写成 gridItem(row = -2, column = -2)。
下面的示例中,蓝色卡片被放在第二行第二列。
绿色被放到最后一行,也就是索引为 -1 的那一行,并自动占据该 track 中第一个可用列位置。
Grid(
config = {
repeat(2) {
column(160.dp)
}
repeat(3) {
row(90.dp)
}
gap(8.dp)
}
) {
RedRoundedBox()
BlueRoundedBox(modifier = Modifier.gridItem(row = 2, column = 2))
GreenRoundedBox(modifier = Modifier.gridItem(row = -1, column = -2))
}
仔细看的话,你会发现 column = -2 竟然是第一个位置,当然了,倒数第二列就是第一列。
跨越
你可以使用 rowSpan 和 columnSpan 参数,让一个条目跨越多个 cell。
你可以把一个 UI 元素放进一个 grid area 中,也就是由多个 grid cell 组成的区域。
gridItem modifier 通过 rowSpan 与 columnSpan 参数来定义这个 grid area。
下面的示例中,红色卡片被放到一个覆盖两行两列的区域里。
Grid(
config = {
repeat(3) {
column(100.dp)
}
repeat(3) {
row(60.dp)
}
gap(8.dp)
}
) {
RedRoundedBox(modifier = Modifier.gridItem(rowSpan = 2, columnSpan = 2))
BlueRoundedBox()
GreenRoundedBox()
OrangeRoundedBox(modifier = Modifier.gridItem(columnSpan = 3))
}
而橘色卡片跨越了三列。
对齐方式
你可以在 gridItem modifier 的 alignment 参数中指定 UI 元素在 grid area 内的对齐方式。
下面的示例中,#1 被放在一个两行两列 grid area 的中心位置。
Grid(
config = {
repeat(3) {
column(100.dp)
}
repeat(3) {
row(60.dp)
}
gap(8.dp)
},
) {
Text(
text = "#1",
modifier = Modifier
.background(Color.LightGray)
.gridItem(
rowSpan = 2,
columnSpan = 2,
alignment = Alignment.Center
),
)
RedRoundedBox()
BlueRoundedBox()
GreenRoundedBox(modifier = Modifier.gridItem(columnSpan = 3))
}
自动布局
在 Grid 中,没有显式指定位置的子项会进入自动放置流程。
下面这个示例展示了如何把自动放置元素与指定 grid cell 的元素混合使用。其中蓝色卡片和橘色卡片指定了 grid cell,其他条目则由系统自动放置。
Grid(
config = {
repeat(2) {
column(100.dp)
}
repeat(3) {
row(60.dp)
}
rowGap(16.dp)
columnGap(8.dp)
}
) {
RedRoundedBox()
BlueRoundedBox(modifier = Modifier.gridItem(row = 2, column = 2))
GreenRoundedBox()
OrangeRoundedBox(modifier = Modifier.gridItem(row = 3, column = 1))
PinkRoundedBox()
GreenRoundedBox()
}
这里其实很好理解这个子项的放置逻辑:
- 红色没有指定位置,自动在 [1, 1];
- 接下来蓝色卡片,蓝色卡片指定了 [2, 2],所以必须放置在**[2, 2]**;
- 再往下,绿色卡片自动放置,目前 [1, 2] 位置有空缺,所以放在该位置;
- 以此类推。
一点想法
如果从响应式布局方面看 FlexBox 和 Grid,意义会更清楚:FlexBox 是在解决“一维流动”和“自动换行”,而 Grid 解决的是“二维编排”和“区域分配”。
这和 Web CSS 里的 Flexbox 与 Grid 的职责划分其实非常接近。前者擅长顺着一个方向安排内容,后者更适合描述页面骨架、模块分区,以及那些需要同时关心行和列的界面。
这也是我觉得 Compose 这套布局能力正在慢慢变成熟的地方。
过去我们在 Android 上做响应式页面,很多时候是在 Row、Column、权重和条件分支里一点点“拼”出来;而现在,FlexBox 和 Grid 让“声明布局意图”这件事变得更自然了。
你不再只是告诉系统某个元素该有多宽多高,而是在告诉它,这个页面应该如何随着空间变化而重新布局。
从这个角度说,Compose 和 Web 前端在布局思想上的距离,其实已经越来越近。无论你平时更熟悉 Android,还是更熟悉 CSS,只要理解了 track、gap、span、auto placement 这些概念,在任何一个环境下编写代码也会顺手很多。
对开发者来说,这不仅仅是多了一个新 API,而是 Compose 在“现代响应式 UI”这条路上,终于补上了一块非常关键的拼图。