第5.3篇:GridRow/GridCol------响应式网格布局
系列 :HarmonyOS 从入门到实践 · 画伴梦工厂实战
难度 :⭐⭐ 进阶
前置知识 :5.1 鸿蒙断点系统原理
涉及源文件 :
features/responsiveLayout/src/main/ets/view/GridComponent.ets、features/responsiveLayout/src/main/ets/constants/CommonConstants.ets
在多设备时代,同一个应用需要在手机、平板、折叠屏和桌面端都能提供良好的视觉体验。传统的固定像素布局在屏幕尺寸变化时往往需要大量适配工作,而 HarmonyOS 提供了一整套响应式网格布局 方案------Grid + GridItem 组件配合断点系统,让布局自适应成为一件优雅的事。
本文将以"画伴梦工厂"项目中 responsiveLayout 特性模块为例,拆解如何用 Grid 容器实现从手机(2列)到平板(3列)再到桌面(4列)的自适应网格。
正式 API 命名中,
Grid和GridItem是鸿蒙网格布局的核心组件,而GridRow/GridCol是更上层的 12 列栅格系统。本文侧重点在Grid+GridItem组合,两种方案原理相通,可互为参考。
一、Grid + GridItem:鸿蒙网格容器基础
Grid 是 ArkUI 中用于创建网格布局的容器组件,GridItem 是其子项。与传统的 Row/Column 线性布局不同,Grid 允许元素沿水平和垂直两个方向排列,天然适合展示卡片列表、相册、商品货架等场景。
项目中的 Grid 声明
typescript
// GridComponent.ets - 核心网格组件
@Component
export struct GridComponent {
@StorageProp('mainBreakpoint') currentBreakpoint: string = commonConstants.breakpointsInitializeName;
@State colTemplate: string = commonConstants.initializeTemplate;
private readonly data: number[] = [];
build() {
Grid() {
ForEach(this.data, () => {
GridItem() {
// 每个网格项包含一个色块 + 文本标签
Column() {
Row()
.width('100%')
.aspectRatio(commonConstants.columnAspectRatio)
.backgroundColor($r('sys.color.ohos_id_color_component_normal'))
Row() {
Text($r('app.string.grid_title_label'))
.fontSize($r('app.float.grid_text_font_size'))
.fontWeight(commonConstants.columnTextFontWeight)
}
}
}
})
}
.columnsTemplate(this.colTemplate)
.columnsGap($r('app.float.grid_column_gap'))
.rowsGap($r('app.float.grid_row_gap'))
}
}
这段代码展示了最基础的 Grid 用法:
| 要素 | 说明 |
|---|---|
Grid |
容器组件,负责整体网格布局 |
GridItem |
子项组件,内部可嵌套任意内容 |
ForEach |
循环渲染数据源,动态生成 GridItem |
columnsTemplate |
定义列数及每列宽度比例 |
columnsGap / rowsGap |
控制列间距和行间距 |
Grid vs Row/Column 的选择
- Row/Column:适合线性排列,一维布局
- Grid:适合二维网格,子项等宽等高排列,自动换行
- GridRow/GridCol:适合 12 列栅格系统,子项可跨列
项目使用 Grid 而非 GridRow,是因为卡片列表需要的是等宽等高的规则网格,而非自由跨列的复杂栅格。
二、columnsTemplate:用 fr 单位构建弹性列
columnsTemplate 是 Grid 最核心的属性,它定义了网格的列数 和每列宽度比例 。其语法类似 CSS Grid 的 grid-template-columns。
fr 弹性单位
typescript
// CommonConstants.ets - 各断点下的列模板
smColTemplate: '1fr 1fr', // 手机:2列
mdColTemplate: '1fr 1fr 1fr', // 平板:3列
lgColTemplate: '1fr 1fr 1fr 1fr', // 桌面:4列
xlColTemplate: '1fr 1fr 1fr 1fr', // 大桌面:4列
fr(fraction)是弹性比例单位,表示将可用空间按照指定份数分配:
'1fr 1fr':可用宽度分成 2 份,每列占 1 份(各 50%),即 2 列'1fr 1fr 1fr':分成 3 份,每列占 1 份(各 33.33%),即 3 列'1fr 1fr 1fr 1fr':分成 4 份,每列占 1 份(各 25%),即 4 列
混合单位对比
fr 可以与其他单位混合使用:
typescript
// 混合写法示例(项目中未使用,但值得了解)
columnsTemplate: '200vp 1fr 1fr' // 第一列固定200,后两列平分剩余空间
columnsTemplate: '1fr 2fr' // 第二列宽度是第一列的2倍
columnsTemplate: 'repeat(3, 1fr)' // 等价于 '1fr 1fr 1fr'
项目中采用清一色的 1fr 来构建均分列宽的网格,所有卡片等宽排列,视觉上整齐统一。
注意:鸿蒙的
columnsTemplate目前不支持repeat()函数,需要手动写全每个 fr 值。
GridItem 的自动放置
当 GridItem 的数量超过列数 × 行数时,Grid 会自动折行。8 个数据项在 2 列模式下显示为 4 行,在 4 列模式下显示为 2 行------无需任何额外布局代码,这就是网格布局的便利之处。
三、断点驱动:aboutToAppear 中的模板初始化
Grid 的列数需要根据当前设备的断点(breakpoint)动态决定。项目在 aboutToAppear 生命周期中完成初始化。
生命周期内初始化
typescript
aboutToAppear() {
// 初始化数据源:生成 0~7 共 8 个索引
for (let i = 0; i < commonConstants.gridSize; i++) {
this.data.push(i);
}
// 根据当前断点选择初始列模板
switch (this.currentBreakpoint) {
case commonConstants.breakpointsSmName: // 'sm'
this.colTemplate = commonConstants.smColTemplate; // '1fr 1fr'
break;
case commonConstants.breakpointsMdName: // 'md'
this.colTemplate = commonConstants.mdColTemplate; // '1fr 1fr 1fr'
break;
case commonConstants.breakpointsLgName: // 'lg'
this.colTemplate = commonConstants.lgColTemplate; // '1fr 1fr 1fr 1fr'
break;
case commonConstants.breakpointsXlName: // 'xl'
this.colTemplate = commonConstants.xlColTemplate; // '1fr 1fr 1fr 1fr'
break;
default:
this.colTemplate = commonConstants.smColTemplate; // 默认 2 列
break;
}
}
这段逻辑的核心思路:
- 读取断点 :从
@StorageProp('mainBreakpoint')获取当前设备断点名称 - switch 分发 :根据
sm/md/lg/xl选择对应的列模板字符串 - 赋值 @State :将模板字符串赋给
@State colTemplate,触发 UI 渲染
@StorageProp 是 HarmonyOS 的全局存储装饰器,它与 AppStorage 中的 'mainBreakpoint' 键绑定。当断点系统检测到屏幕尺寸变化并更新 AppStorage 中的值时,所有通过 @StorageProp 订阅该键的组件都会自动收到新值。
数据源初始化与 gridSize
typescript
// CommonConstants.ets
gridSize: 8, // 固定 8 个网格项
gridSize 设置为 8,是一个精心选择的值:
- 2 列模式下:4 行,刚好铺满一屏
- 3 列模式下:3 行(最后一行 2 个),视觉均衡
- 4 列模式下:2 行,内容紧凑
如果数据量更大的场景,可以配合 List + LazyForEach 实现虚拟列表加载。
四、onAreaChange + BreakPointType:运行时动态换模板
仅仅在 aboutToAppear 中初始化还不够------当用户旋转设备或从平板模式切换到桌面模式时,列数需要实时变化 。项目通过 Grid 的 onAreaChange 事件实现了这一能力。
事件监听与模板更替
typescript
Grid() {
// ... grid items
}
.columnsTemplate(this.colTemplate)
.onAreaChange(() => {
// 每次网格区域尺寸变化时重新计算列模板
this.colTemplate = new BreakPointType({
sm: commonConstants.smColTemplate, // '1fr 1fr'
md: commonConstants.mdColTemplate, // '1fr 1fr 1fr'
lg: commonConstants.lgColTemplate, // '1fr 1fr 1fr 1fr'
xl: commonConstants.xlColTemplate // '1fr 1fr 1fr 1fr'
}).getValue(this.currentBreakpoint);
})
执行流程
设备旋转 / 窗口缩放
│
▼
onAreaChange 触发
│
▼
new BreakPointType({...})
.getValue(this.currentBreakpoint) ← 读取当前断点
│
▼
返回对应的列模板字符串
│
▼
this.colTemplate = '1fr 1fr 1fr' ← 更新 @State
│
▼
Grid 组件重新渲染,列数变化
BreakPointType 的角色
BreakPointType<T> 是 HarmonyOS 提供的一个工具泛型,它将一个对象映射为"根据断点名称获取对应值"的能力:
typescript
// BreakPointType 的本质
class BreakPointType<T> {
sm?: T;
md?: T;
lg?: T;
xl?: T;
getValue(currentBreakpoint: string): T {
// 根据 currentBreakpoint 返回对应的值
}
}
在项目中,BreakPointType 被反复用于各种断点差异化配置------不仅限于列模板,还用于 Tab 方向、间距、内边距等。这种模式让断点相关的逻辑高度集中、易于维护。
为什么不直接用 @StorageProp 的监听?
有人可能会问:既然 currentBreakpoint 是响应式的(@StorageProp),为什么不直接监听它的变化来自动更新 colTemplate?
原因在于:Grid 的重新布局需要触发 onAreaChange 事件来获得正确的区域尺寸 。单纯更新 colTemplate 变量虽然会触发 UI 重绘,但 onAreaChange 提供了额外的一层保障------确保 Grid 容器在尺寸变化稳定后才更新模板,避免在动画帧过程中频繁切换列数导致的闪烁。
五、columnsGap / rowsGap:间距控制与视觉节奏
间距是网格布局中影响视觉品质的关键因素。项目中通过资源文件统一管理间距值:
typescript
Grid()
.columnsGap($r('app.float.grid_column_gap'))
.rowsGap($r('app.float.grid_row_gap'))
列间距 vs 行间距
| 属性 | 作用 | 典型值 |
|---|---|---|
columnsGap |
水平方向相邻列的间距 | grid_column_gap(资源文件定义) |
rowsGap |
垂直方向相邻行的间距 | grid_row_gap(资源文件定义) |
通过资源引用($r())而非硬编码数值,间距值可以在不同设备上差异化配置------手机端间距小、桌面端间距大,进一步提升响应式体验。
间距与 fr 的配合
间距会在 fr 分配之前从可用宽度中扣除。例如:
总宽度 = 1000vp
columnsGap = 16vp
3 列 → 两列之间各 16vp 间距 → 有效宽度 = 1000 - 16×2 = 968vp
每列宽度 = 968 ÷ 3 ≈ 322.67vp
这个计算由框架自动完成,开发者只需声明列数和间距。
六、aspectRatio:响应式卡片比例控制
为了让网格卡片在列宽变化时保持合理的视觉比例,项目使用了 aspectRatio 属性:
typescript
// CommonConstants.ets
columnAspectRatio: 16 / 9,
// GridComponent.ets
Row()
.width('100%')
.aspectRatio(commonConstants.columnAspectRatio) // 宽高比 16:9
aspectRatio 的工作原理
aspectRatio 定义组件的宽高比(width / height)。当宽度固定时,高度自动计算:
aspectRatio = 16 / 9 ≈ 1.778
width = 100%(父容器宽度)
height = width / 1.778
在不同列数下,卡片宽度不同,但比例一致:
| 断点 | 列数 | 卡片宽度(估算) | 卡片高度(16:9) |
|---|---|---|---|
| sm | 2 | 约 50% 容器宽度 | 约 28% |
| md | 3 | 约 33% 容器宽度 | 约 18.8% |
| lg/xl | 4 | 约 25% 容器宽度 | 约 14% |
这样就实现了比例恒定、大小自适应的卡片效果------无论屏幕多宽,卡片始终是规整的 16:9 矩形。
常用宽高比参考
| 比值 | 适用场景 |
|---|---|
1 / 1 |
正方形头像、正方形卡片 |
16 / 9 |
视频缩略图、横屏内容 |
4 / 3 |
照片、传统显示比例 |
3 / 4 |
竖版卡片、人物照片 |
2 / 3 |
商品列表、竖版内容 |
七、响应式列数策略:2→3→4 的断点设计
项目的网格列数策略可以总结为一张表:
┌────────┬────────────┬──────────┬──────────────┐
│ 断点 │ 设备典型 │ 列数 │ columnsTemplate │
├────────┼────────────┼──────────┼──────────────┤
│ sm │ 手机竖屏 │ 2列 │ '1fr 1fr' │
│ md │ 平板竖屏 │ 3列 │ '1fr 1fr 1fr' │
│ lg │ 桌面 / 横屏 │ 4列 │ '1fr 1fr 1fr 1fr' │
│ xl │ 大屏桌面 │ 4列 │ '1fr 1fr 1fr 1fr' │
└────────┴────────────┴──────────┴──────────────┘
设计考量
-
手机(sm)2 列:手机屏幕宽度有限,2 列保证了卡片有足够大的点击区域和可视化面积,避免内容过于拥挤。
-
平板(md)3 列:平板屏幕宽度约 600~800vp,3 列能在不牺牲卡片可读性的前提下展示更多内容。
-
桌面(lg/xl)4 列:桌面宽度充足,4 列充分利用宽屏空间。lg 和 xl 复用同一模板,因为再增加列数会导致卡片过窄,反而降低可读性。
这种"阶梯式"列数增长是一种经过验证的响应式策略------在保证内容可读性的前提下,用最少的断点覆盖最多的设备。
与其他响应式策略对比
| 策略 | 特点 | 适用场景 |
|---|---|---|
| 阶梯式列数(本项目) | 断点切换时列数跳跃变化 | 内容型卡片、媒体展示 |
| 流体网格 | 列数固定,列宽随容器伸缩 | 文本为主的布局 |
| 混合网格 | 部分列固定、部分列弹性 | 带侧边栏的复杂布局 |
| 12列栅格系统 | 子项自由跨列组合 | 页面级大布局 |
八、@StorageProp + 断点系统:全局响应式联动
GridComponent 通过 @StorageProp 与全局断点系统建立连接,这是整个响应式机制的数据基础。
数据流链路
BreakpointSystem(监听屏幕尺寸变化)
│
▼
AppStorage.set('mainBreakpoint', 'sm'|'md'|'lg'|'xl')
│
▼
@StorageProp('mainBreakpoint') currentBreakpoint
│
▼
GridComponent 内部消费 currentBreakpoint
│
├── aboutToAppear → switch → colTemplate 初始值
└── onAreaChange → BreakPointType.getValue → colTemplate 更新
@StorageProp 详解
typescript
@StorageProp('mainBreakpoint') currentBreakpoint: string
@StorageProp:从AppStorage中读取属性,并建立单向绑定。当AppStorage中'mainBreakpoint'的值变化时,currentBreakpoint自动更新。- 单向绑定 :组件可以读取和消费该值,但不能修改它------断点值的修改权在
BreakpointSystem中,保证了数据流的单向性。 - 初始化值 :
commonConstants.breakpointsInitializeName值为'md',在断点系统尚未完成初始化时作为保底值。
与 @StorageLink 的区别
┌─────────────┬─────────────────────┬────────────────────┐
│ 装饰器 │ 方向 │ 用途 │
├─────────────┼─────────────────────┼────────────────────┤
│ @StorageProp │ AppStorage → 组件 │ 只读消费全局状态 │
│ @StorageLink │ AppStorage ↔ 组件 │ 双向读写全局状态 │
└─────────────┴─────────────────────┴────────────────────┘
在 ResponsiveLayout.ets(父页面)中使用了 @StorageLink,因为它需要主动修改当前索引等状态;而在 GridComponent 中只需读取断点值,因此使用 @StorageProp------语义更清晰、权限更受限。
九、与 CSS Grid 的对比(开发经验迁移)
对于有 Web 开发背景的读者,理解鸿蒙 Grid 与 CSS Grid 的异同可以加速上手:
| 维度 | CSS Grid(Web) | HarmonyOS Grid(ArkUI) |
|---|---|---|
| 容器 | display: grid |
<Grid> 组件 |
| 子项 | 直接子元素 | <GridItem> 包裹 |
| 列定义 | grid-template-columns: 1fr 1fr 1fr |
.columnsTemplate('1fr 1fr 1fr') |
| 行定义 | grid-template-rows |
.rowsTemplate() |
| 间距 | gap / row-gap / column-gap |
.columnsGap() / .rowsGap() |
| 跨列 | grid-column: span 2 |
需手动调整 GridItem 位置 |
| 自动折行 | auto-fill / auto-fit |
自动折行,无需额外属性 |
| 响应式 | @media (min-width: 768px) |
BreakPointType + onAreaChange |
| fr 单位 | fr(CSS spec) |
fr(ArkUI 属性值字符串) |
| repeat() | 支持 repeat(3, 1fr) |
暂不支持,需手动展开 |
鸿蒙 Grid 的独特优势
- 断点系统原生集成 :
BreakPointType+@StorageProp是框架级别的能力,无需额外引入媒体查询库 - 性能优化 :GridItem 配合
LazyForEach支持按需加载,长列表场景下性能优于 Web 端手动实现 - 类型安全:模板字符串在编译时检查,避免 CSS 中拼写错误导致的静默失败
- 资源响应式 :
$r('app.float.xxx')资源引用可与断点联动,间距、字号等同步适配
十、完整布局结构图
将本文涉及的所有知识点整合为 GridComponent 的整体布局架构:
┌─────────────────────────────────────────────────┐
│ Grid │
│ columnsTemplate={colTemplate} │
│ columnsGap / rowsGap │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ GridItem │ │ GridItem │ │ GridItem │ │
│ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │ Row │ │ │ │ Row │ │ │ │ Row │ │ │
│ │ │16:9 │ │ │ │16:9 │ │ │ │16:9 │ │ │
│ │ └──────┘ │ │ └──────┘ │ │ └──────┘ │ │
│ │ Text │ │ Text │ │ Text │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ GridItem │ │ GridItem │ │ GridItem │ │
│ │ ... │ │ ... │ │ ... │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ← onAreaChange → BreakPointType → colTemplate → │
└─────────────────────────────────────────────────┘
总结
本文通过"画伴梦工厂"中的 GridComponent,完整拆解了 HarmonyOS 响应式网格布局的实现方案:
| 知识点 | 核心实现 |
|---|---|
| Grid + GridItem 容器 | 二维网格布局,自动折行排列子项 |
| columnsTemplate + fr 单位 | 定义列数和弹性比例,'1fr 1fr' 表示 2 列等宽 |
| 断点初始化 | aboutToAppear + switch 根据 currentBreakpoint 设置初始模板 |
| 动态换模板 | onAreaChange + BreakPointType 实时响应尺寸变化 |
| 间距控制 | columnsGap / rowsGap 统一管理水平和垂直间距 |
| 卡片比例 | aspectRatio(16/9) 保证卡片比例稳定 |
| 列数策略 | 2 列(手机)→ 3 列(平板)→ 4 列(桌面)阶梯式递增 |
| 全局响应式联动 | @StorageProp('mainBreakpoint') 连接断点系统与 Grid 组件 |
响应式网格布局的精髓在于:用最少的规则应对最多的设备。通过断点、模板、比例三要素的组合,一套 Grid 代码即可完美适配手机、平板和桌面------这正是 HarmonyOS 响应式设计框架的核心价值所在。
参考源码
本文所有代码均来自项目文件:
features/responsiveLayout/src/main/ets/view/GridComponent.ets--- 核心 Grid 网格组件,包含初始化、断点切换、模板更新完整逻辑features/responsiveLayout/src/main/ets/constants/CommonConstants.ets--- 断点名称、列模板、间距等所有常量配置features/responsiveLayout/src/main/ets/pages/ResponsiveLayout.ets--- 父页面,展示 BreakPointType 在 Tab 布局中的更多应用