鸿蒙原生ArkTS布局方式之Grid+scrollBar滚动条布局鸿蒙原生ArkTS布局方式之Grid+scrollBar滚动条布局
鸿蒙 ArkTS 布局实战:Grid + scrollBar 大型网格滚动——海量数据的网格化浏览之道
本文基于 HarmonyOS NEXT 6.1.1(API 24)与 DevEco Studio 开发环境,以一个可直接运行的 ArkTS 示例应用为载体,深入讲解鸿蒙原生布局体系中"Grid + scrollBar 滚动条布局"的完整用法:从"整页滚动与容器内滚动"的本质区别、固定 height 的布局哲学,到滚动条的显隐控制与编程式滚动,再到一个 48 件商品的大型网格的真实落地实现,并给出全部可运行代码与详细中文注释。
目录
- 引言:当数据量突破一屏,滚动就成了刚需
- 环境准备:DevEco Studio 与工程创建
- 布局选型:整页滚动,还是网格内部滚动?
- Grid 核心概念回顾:columnsTemplate、fr 单位与 GridItem
- 核心技术点一:固定 height——网格内部滚动的开关
- 核心技术点二:scrollBar——滚动条的显隐与样式控制
- 核心技术点三:Scroller 控制器——编程式滚动
- 实战解析:大型商品网格的数据模型与动态生成
- 页面构建详解:从卡片组件到完整布局
- 完整代码清单
- 页面注册与运行效果
- 进阶话题:性能优化与 LazyForEach 懒加载
- 总结
一、引言:当数据量突破一屏,滚动就成了刚需
上一篇文章我们聊了 Grid 固定列数网格,用 columnsTemplate('1fr 1fr 1fr') 一行代码就让 9 件商品整整齐齐地排成了 3 列。然而真实的业务场景远没有这么温柔——打开一个电商 App,商品可能是几百件、几千件;打开日历应用,一整年的日期需要从上到下逐月浏览。当数据量突破一屏,滚动就成了刚需。
那么问题来了:滚动该怎么做?
- 方案 A:整个页面用
Scroll组件包起来,所有内容一起上下滚动; - 方案 B:页面保持不动,只有网格区域自己内部滚动,滚动条出现在网格右侧指示位置。
本文要讲的就是方案 B 的实现——Grid + scrollBar 滚动条布局,核心是三个关键词:
- Grid:高性能网格容器,负责分列排布海量数据;
- scrollBar:滚动条属性,让用户"看见"自己滚到了哪里;
- height:固定高度,这是 Grid 进入"内部滚动"的前提。
三者缺一不可、各司其职,而且这套规律不仅适用于 Grid,同样适用于 List、Scroll 等所有可滚动容器。
在动手之前,先来看一眼示例应用最终的样子:4 列大型商品网格,48 件商品在固定高度的区域内上下滚动,右侧一条蓝色细滚动条随滚动浮现又淡出;顶部有"回到顶部"和"下一页"两个按钮,点击即可编程式操控网格滚动。整份代码不过两百行,但蕴含的布局思想足以应对绝大多数大型列表场景。
二、环境准备:DevEco Studio 与工程创建
在写代码之前,先把开发环境捋清楚。本文示例基于以下环境:
| 项目 | 版本 / 说明 |
|---|---|
| 操作系统 | Windows 10/11 及以上 |
| 开发工具 | DevEco Studio(HarmonyOS NEXT 版本) |
| SDK | HarmonyOS NEXT 6.1.1(API 24) |
| 开发语言 | ArkTS(TypeScript 的鸿蒙超集) |
| 应用模型 | Stage 模型 |
| 目标设备 | 手机 / 平板(横竖屏均可) |
Step 1:创建工程
打开 DevEco Studio,依次选择 File → New → Create Project,在模板列表中选择 Empty Ability(空工程模板),填写工程名称(例如 GridScrollDemo)、包名与保存路径,点击 Finish 完成创建。创建完成后,工程会自动生成一个标准的 Stage 模型骨架,与本文相关的主要目录如下:
GridScrollDemo/
├── AppScope/ # 应用级配置
├── entry/
│ └── src/
│ └── main/
│ ├── ets/
│ │ ├── entryability/ # 入口 Ability
│ │ └── pages/ # 页面目录(我们在这里写页面)
│ ├── resources/ # 资源目录
│ └── module.json5 # 模块配置
└── build-profile.json5 # 工程构建配置
Step 2:确认 SDK 与 API 版本
打开工程根目录的 build-profile.json5,确认 compatibleSdkVersion 与 targetSdkVersion 与 HarmonyOS NEXT 6.1.1(API 24)相匹配。不同的 API 版本决定了 ArkUI 组件的可用属性集合,本文用到的 columnsTemplate、scrollBar、scrollBarWidth、scrollBarColor、edgeEffect 以及 scrollToIndex / scrollPage 等 API 在 API 24 上均已稳定支持。
Step 3:规划页面文件
默认工程会在 entry/src/main/ets/pages/ 下生成 Index.ets 作为首页。我们新建独立的 GridScrollBar.ets 承载示例代码,稍后注册到 main_pages.json 中,与默认首页并存,便于对比学习。
三、布局选型:整页滚动,还是网格内部滚动?
这是很多初学者接触大型列表时的第一个困惑。先从用户视角对比两种方案的体验差异。
3.1 方案 A:整页滚动(外层 Scroll 包裹)
上一篇文章的示例就是这种结构:最外层一个 Scroll,里面装标题、说明文字、若干个 Grid,所有内容作为一个整体上下滑动。
// 方案 A 示意:整页滚动
Scroll() {
Column() {
Text('页面标题') // 标题跟着滚动
Grid() { /* 商品网格 */ } // 网格跟着滚动
}
}
.height('100%')
这种方案的优点是结构简单、心智负担小,整个页面就是一个"长文档",适合内容总量不大、页面结构固定的页面。但短板有二:
- 导航区无法固定:顶部操作按钮(比如"回到顶部")会随内容一并滚出屏幕;
- 滚动状态全局化:多块独立数据共用一个滚动条,无法单独感知某块数据的滚动进度。
3.2 方案 B:容器内部滚动(Grid + scrollBar + height)
方案 B 的思路恰好相反:页面本身不动,让数据量最大的那块网格区域在固定高度内自己滚动。
// 方案 B 示意:网格内部滚动
Column() {
Row() { /* 固定在顶部的操作栏,不随网格滚动 */ }
Grid() { /* 48 件商品,超高后在内部滚动 */ }
.height('88%') // 关键:固定高度,高度封顶
.scrollBar(BarState.Auto) // 滚动条自动显隐
}
.height('100%')
这种方案的优势非常明显:
- 操作区常驻:“回到顶部”"下一页"等按钮固定在顶部,无论网格滚到哪里都可以随时操作;
- 滚动条可感知:滚动条贴着网格区域右侧显示,用户一眼就能看出"这块网格总共多长、我看到了百分之几";
- 互不干扰:页面可以有多个独立滚动的区域,各自拥有独立的滚动条和滚动进度。
在真实的鸿蒙应用里,方案 B 才是主流——朋友圈的图片九宫格、手机相册的缩略图墙、电商的分类商品列表、日历的年视图,几乎都是"固定高度的容器 + 内部滚动"的形态。
3.3 一句话总结选型原则
内容少、结构简单 → 整页滚动;数据量大、需要操作区常驻或进度可见 → 容器内部滚动。
本文的示例应用选用方案 B,核心代码只有三行属性,却解决了大型网格浏览的全部痛点:
.height('88%') // 1. 固定高度:触发内部滚动
.scrollBar(BarState.Auto) // 2. 滚动条:指示浏览位置
.edgeEffect(EdgeEffect.Spring) // 3. 边缘回弹:提升手感
四、Grid 核心概念回顾:columnsTemplate、fr 单位与 GridItem
在进入新布局之前,先把 Grid 的基础概念快速过一遍。上一篇文章已经详细讲过,这里做一次精简回顾,作为新内容的地基。
4.1 Grid 是什么
Grid 是 ArkUI 的高性能网格布局容器:按列模板把可用宽度切分成若干等宽或不等宽的列,子组件依次放入网格单元,排列不下时自动换行。它内部复用了 List 的懒加载与滚动机制,数据量再大也能流畅滚动。
4.2 columnsTemplate:声明列模板
columnsTemplate 是 Grid 最核心的属性,它用字符串声明列的划分方式:
.columnsTemplate('1fr 1fr 1fr 1fr') // 4 列,等宽
.columnsTemplate('1fr 2fr 1fr') // 3 列,中间列是两侧的 2 倍
.columnsTemplate('repeat(4, 1fr)') // 4 列,等宽(repeat 语法)
其中 fr 是比例单位,表示"按比例瓜分剩余宽度"。'1fr 1fr 1fr 1fr' 即把容器宽度平均分成 4 份,每列占一份。
4.3 GridItem:网格单元
Grid 的直接子组件只能是 GridItem,每个 GridItem 占据一个网格单元。我们平时写的卡片内容(商品图、名称、价格)都要包在 GridItem 里:
Grid() {
ForEach(this.goodsList, (item: GridGoods) => {
GridItem() {
// 网格单元内放置商品卡片
this.GoodsCard(item)
}
}, (item: GridGoods) => item.id.toString())
}
4.4 columnsGap / rowsGap:行列间距
.columnsGap(10) // 列间距 10vp
.rowsGap(10) // 行间距 10vp
这两行属性让网格单元之间留出呼吸感,避免卡片挤在一起。间距单位是 vp(虚拟像素),它是鸿蒙的响应式长度单位,会自动适配不同屏幕密度。
至此,网格的"形状"已经确定:4 列、等宽、间距 10vp。接下来我们正式进入本文的核心——如何让这个网格在固定高度内滚动,并让滚动条向用户"汇报"位置。
五、核心技术点一:固定 height——网格内部滚动的开关
如果只能从本文记住一句话,那就是这一句:Grid 是否产生内部滚动,取决于它的高度是否封顶。这是理解整个布局方式的钥匙。
5.1 高度 auto:随内容撑开,永不滚动
先看一个反例。如果把 Grid 的高度设为 'auto'(不设置 height 时的默认行为):
Grid() {
ForEach(this.goodsList, (item: GridGoods) => {
GridItem() { this.GoodsCard(item) }
})
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.width('100%')
.height('auto') // ← 高度随内容自适应
.scrollBar(BarState.Auto)
此时 Grid 的行为是:把所有网格单元全部排布出来,Grid 的总高度 = 所有行的高度之和。48 件商品 4 列排 12 行,总高度远超一屏,于是——
- Grid 自身不会滚动,因为它"没有可滚动的空间"——它的高度本身就等于全部内容的高度;
- 超出屏幕的部分会被裁切掉,或者把外层容器顶出屏幕,用户根本看不到后面的商品;
scrollBar属性形同虚设,因为根本没有滚动发生,自然没有滚动条。
这正是初学者最容易踩的坑:写了 scrollBar(BarState.Auto),却忘记给高度,结果滚动条死活不出现,还以为是 API 写错了。
5.2 高度固定:高度封顶,滚动发生
现在把高度改成固定值:
.height('88%') // 网格高度封顶为容器剩余空间的 88%
一瞬间局面就反转了:Grid 的可用高度被锁死在一个固定值,而内容(12 行网格单元)的总高度远超这个值——内容与容器之间出现了"高度差",这个高度差就是 Grid 内部滚动的驱动源。Grid 内部维护一个滚动视口,用户滑动浏览到的永远是"总内容的一个窗口"。
用一张表来总结这个开关的本质:
| 高度设置 | 表现 | 是否内部滚动 | 滚动条是否有意义 |
|---|---|---|---|
'auto' / 不设置 |
高度随内容撑开,超出部分不可见 | 否 | 无 |
固定 vp(如 400) |
高度锁死为 400vp,内容超高 | 是 | 有 |
百分比(如 '88%') |
高度 = 容器高度的 88%,内容超高 | 是 | 有 |
5.3 为什么本例用 ‘88%’ 而不是具体数值
示例页面是一个 Column:顶部是标题区与操作栏,剩下的空间全部留给网格。如果写死 height(600),屏幕矮时网格会顶出底部,屏幕高时又留出大片空白;用百分比 '88%' 让网格"吃掉"剩余空间的 88%,无论屏幕多高多矮都能自适应铺满——这正是响应式布局的基本功。
为什么不直接用 100%?因为 Column 里除了网格还有其他内容(标题、按钮、底部留白),网格占满后它们就无处安放了。留出 12% 给其他元素,既不溢出,又最大化网格可视区域。
5.4 高度与滚动方向的关系
height 管的是纵向滚动(上下滑动)。Grid 同样支持横向滚动:用 rowsTemplate 声明行模板并固定 width 即可。本文聚焦纵向,但"固定主轴尺寸 → 触发内部滚动"的规律完全一致,一通百通。
六、核心技术点二:scrollBar——滚动条的显隐与样式控制
滚动已经发生了,但用户怎么知道"自己滚到了哪里、还有多少没看完"?这就轮到 scrollBar 登场。它是本布局方式最直观的"门面",也是让大型网格滚动体验专业化的关键。
6.1 BarState:三种滚动条状态
scrollBar() 接受一个 BarState 枚举值,共三档:
.scrollBar(BarState.Auto) // 自动:滚动时出现,停止后淡出
.scrollBar(BarState.On) // 常显:始终显示滚动条
.scrollBar(BarState.Off) // 隐藏:永不显示(滚动仍然可用)
| 状态 | 显示时机 | 典型场景 |
|---|---|---|
BarState.Auto |
用户滚动时出现,停止约 1 秒后淡出 | 绝大多数列表 / 网格,本文示例 |
BarState.On |
始终常驻 | 数据量极大、需要随时感知位置的场景(如代码编辑器、股票列表) |
BarState.Off |
从不显示 | 视觉纯净、滚动条会干扰内容观感的场景(如瀑布流图片墙) |
示例应用选用 BarState.Auto:平时滚动条隐藏,不遮挡商品卡片;一旦手指滑动,一条细长的滚动条立即在网格右侧浮现,滚动停止后缓缓淡出。这种"用时现身、闲时隐身"的交互,正是鸿蒙系统级组件的一致体验。
6.2 scrollBarWidth:滚动条宽度
默认滚动条只有 4vp 宽,在示例中我们加宽到 6vp,让它在演示时更醒目:
.scrollBarWidth(6) // 滚动条宽度 6vp(默认 4vp)
宽度单位同样是 vp。正式产品一般保持默认或略加宽即可——过宽挤占内容区域,过窄难以点按拖拽。
6.3 scrollBarColor:滚动条颜色
滚动条默认是半透明的系统灰。示例中我们将其染成品牌蓝,与页面主色呼应:
.scrollBarColor('#4A90D9') // 滚动条颜色:品牌蓝
颜色可以传十六进制字符串,也可以传资源引用 $r('app.color.xxx')。在实际项目中,建议把颜色收敛到资源文件统一管理,便于主题换肤。
6.4 滚动条与 edgeEffect 的配合
滚动条解决了"位置可见",edgeEffect 则解决"边界手感"。示例中的一行:
.edgeEffect(EdgeEffect.Spring) // 边缘回弹效果
当网格滚到顶部或底部继续拖拽时,内容会像弹簧一样被拉出再弹回,而不是生硬地撞墙。它和滚动条一样,是"专业滚动体验"的重要组成部分——用户高频滚动时,两者的配合直接决定应用质感。
6.5 一个完整的滚动条配置串
把上面的属性串起来,就是示例应用中 Grid 的滚动配置:
Grid(this.scroller) {
// ...GridItem 内容...
}
.columnsTemplate('1fr 1fr 1fr 1fr') // 4 列等宽
.columnsGap(10) // 列间距
.rowsGap(10) // 行间距
.width('100%') // 宽度铺满
.height('88%') // 高度封顶 → 触发内部滚动
.scrollBar(BarState.Auto) // 滚动条自动显隐
.scrollBarWidth(6) // 滚动条宽度
.scrollBarColor('#4A90D9') // 滚动条颜色
.edgeEffect(EdgeEffect.Spring) // 边缘回弹
注意第一行的 Grid(this.scroller)——括号里传入的是一个滚动控制器。它正是下一章的主角:Scroller。
七、核心技术点三:Scroller 控制器——编程式滚动
滚动条让用户能"手动"滚动,但有些场景需要程序主动控制滚动——比如点"回到顶部"一键复位、点"下一页"整屏翻页、浏览完一轮后自动跳回开头。这时就要用到 Scroller。
7.1 创建控制器并与 Grid 绑定
Scroller 是 ArkUI 提供的滚动控制器类,用法分两步。第一步,在组件中声明一个控制器实例:
@Entry
@Component
struct GridScrollBar {
// 网格控制器:用于编程式滚动(回到顶部、翻页)
private scroller: Scroller = new Scroller();
build() {
// 第二步:把控制器传入 Grid 构造参数,建立绑定关系
Grid(this.scroller) {
// ...
}
}
}
Grid(this.scroller) 的构造函数签名,让控制器与网格建立了唯一绑定——之后调用 scroller 的任何滚动方法,操作的都是这个网格。
7.2 scrollToIndex:滚动到指定索引
scrollToIndex(index) 让网格直接滚动到指定索引的网格单元处:
// 回到顶部:滚动到第 0 个网格单元
Button('⬆ 回到顶部')
.onClick(() => {
this.scroller.scrollToIndex(0);
})
在 48 件商品的网格中,用户可能已经滑到了第 40 件,点一下"回到顶部"即可滚回第一件。该方法还支持 smooth 参数:true 平滑滚动,false 直接跳转,示例默认平滑模式。
7.3 scrollPage:按页翻动
scrollPage 按"一屏"为单位翻动,配合"下一页"按钮使用:
// 翻到下一页:scrollPage({ next: true })
Button('⤓ 下一页')
.onClick(() => {
this.scroller.scrollPage({ next: true });
})
参数 { next: true } 向下滚一屏,{ next: false } 向上滚一屏。这个 API 非常适合阅读器翻页、相册分页浏览等场景。
7.4 控制器的完整用法清单
Scroller 的常用方法不止这两个,这里一并列出,方便按需取用:
this.scroller.scrollToIndex(0); // 滚动到指定索引(本例:回到顶部)
this.scroller.scrollPage({ next: true }); // 向下翻一屏
this.scroller.scrollEdge(Edge.Top); // 滚动到顶部边缘
this.scroller.scrollEdge(Edge.Bottom); // 滚动到底部边缘
this.scroller.currentOffset(); // 获取当前滚动偏移量
7.5 为什么操作栏要放在 Grid 外面
编程式滚动之所以"管用",是因为操作栏(按钮)在 Grid 外面——按钮常驻屏幕顶部,不随网格滚动。这正是第三章方案 B 的布局红利:操作区固定 + 内容区独立滚动 + 控制器桥接两者,三者构成完整交互闭环。
八、实战解析:大型商品网格的数据模型与动态生成
理论讲完,进入实战。这一章我们从数据层开始,一步步搭建示例应用的完整代码。
8.1 数据模型:GridGoods 类
大型网格要展示的数据,需要先建模。示例中定义一个 GridGoods 类,模拟真实场景中的商品实体:
// 网格单元数据模型:用于大型商品网格场景
class GridGoods {
id: number; // 唯一标识(作为 ForEach 的 key,保证精确刷新)
name: string; // 商品名称
price: number; // 商品价格(数字便于演示排序)
color: string; // 占位色块颜色(实际项目此处应为商品图片资源)
icon: string; // 占位图标(用 emoji 模拟商品图,保证示例无需额外资源即可运行)
constructor(id: number, name: string, price: number, color: string, icon: string) {
this.id = id;
this.name = name;
this.price = price;
this.color = color;
this.icon = icon;
}
}
每个字段都有其设计考量:
- id:唯一主键,也是
ForEachkey 生成器的核心——数据增删改时,框架能精确定位到变化的网格单元,实现最小化刷新,而不是整个网格重建; - name / price:展示字段。price 用数字类型,方便后续演示价格排序、求和等业务逻辑;
- color / icon:占位资源。真实项目中这里是
Image组件 + 图片资源,但为了让示例"零资源即可运行",我们用色块 + emoji 模拟商品图。color每件不同,便于肉眼确认"网格确实在滚动"。
8.2 动态生成 48 件商品
数据模型定义好之后,写一个生成函数,动态造出 48 件商品。这一步的意义在于:模拟真实场景中从网络或数据库加载出来的海量数据——手工写 48 条数据既不现实也没必要,真实项目里的数据也一定是运行时加载的:
// 动态生成 n 件商品,模拟真实场景下从网络 / 数据库加载出的大量数据
generateGoods(count: number): GridGoods[] {
const names: string[] = ['无线耳机', '智能手表', '蓝牙音箱', '机械键盘', '电竞鼠标', '4K显示器',
'充电宝', '手机支架', '降噪耳麦', '平板电脑', '智能台灯', '空气净化器'];
const colors: string[] = ['#4A90D9', '#50B5A9', '#F5A623', '#7B6FD0', '#E8634A', '#3BB0DB',
'#8B9D5A', '#C77B8E', '#5B8D6E', '#E67E22', '#16A085', '#8E44AD'];
const icons: string[] = ['🎧', '⌚', '🔊', '⌨️', '🖱️', '🖥️', '🔋', '📱', '🎙️', '📟', '💡', '🌀'];
const list: GridGoods[] = [];
for (let i = 0; i < count; i++) {
// 每个单元格取不同的名字 / 颜色 / 图标,并让价格随序号递增,方便肉眼确认网格在滚动
list.push(new GridGoods(
i,
`${names[i % names.length]} ${i + 1}号`,
99 + i * 17,
colors[i % colors.length],
icons[i % icons.length]
));
}
return list;
}
这个函数有两个精心设计的细节:
- 取模轮换:
i % names.length让 12 种名字、12 种颜色、12 种图标循环使用,48 件商品里每种出现 4 次,视觉上"丰富但不乱"; - 价格递增:
99 + i * 17让价格随序号单调递增——第一件 ¥99,最后一件 ¥898。滚动时价格持续变大,用户能直观确认"我确实在往下滚动",而不是看到了重复的静态页面。这个技巧在做演示、写教程时非常实用。
8.3 状态管理与初始化
生成好的数据放入 @State 装饰的数组中,并在组件声明时直接初始化:
@Entry
@Component
struct GridScrollBar {
// 大型网格数据集:动态生成 48 件商品(4 列 × 12 行),高度远超一屏,用于演示网格内部滚动
@State goodsList: GridGoods[] = this.generateGoods(48);
// 网格列数:'1fr 1fr 1fr 1fr' 表示均分为 4 列
private columns: string = '1fr 1fr 1fr 1fr';
// 网格控制器:用于编程式滚动(回到顶部、翻页)
private scroller: Scroller = new Scroller();
}
@State 是 ArkUI 的状态管理装饰器:当 goodsList 的数据发生变化(增删改)时,框架会自动重新渲染与之绑定的 UI。这也是 ForEach 需要唯一 key 的原因——状态驱动刷新时,key 决定了哪些网格单元需要更新、哪些可以复用。示例中 goodsList 初始化后不再变化,但这一套"状态 + 唯一 key"的写法,是后续做下拉刷新、加载更多时的标准范式。
九、页面构建详解:从卡片组件到完整布局
数据就绪,接下来把数据"画"出来。这一章按照从内到外的顺序,解析页面每一层的构建细节。
9.1 商品卡片:@Builder 复用 UI 片段
网格单元里放什么?是一张商品卡片。卡片在 48 个单元里重复出现,必须用 @Builder 抽取成可复用的 UI 片段:
// @Builder 抽取可复用的 UI 片段:传入一个商品对象,即可生成一张网格卡片
@Builder
GoodsCard(item: GridGoods) {
Column() {
// 商品占位图:实际项目请替换为 Image($r('app.media.xxx'))
// 这里用圆角色块 + emoji 模拟商品图,保证示例无需额外图片资源即可运行
Stack() {
Text(item.icon)
.fontSize(34)
}
.width('100%')
.height(80)
.borderRadius(10)
.backgroundColor(item.color)
// 商品名称
Text(item.name)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ top: 6 })
.maxLines(1) // 单行显示,超长省略
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 商品价格
Text(`¥${item.price}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E64340')
.margin({ top: 2 })
}
.width('100%')
.padding(8)
.borderRadius(10)
.backgroundColor('#FFFFFF')
// 点击卡片弹出提示,直观验证每个网格单元都可交互
.onClick(() => {
promptAction.showToast({
message: `点击了:${item.name},价格 ¥${item.price}`,
duration: 1500
});
})
}
卡片内部是一个三层的 Column:占位图(Stack 容器居中放置 emoji)→ 商品名称 → 商品价格。三个容易被忽略的细节:
- maxLines + textOverflow:名称限制单行,超出部分以省略号结尾。网格单元宽度有限,商品名一长就会被挤变形,这两行属性是网格卡片的"标配防爆装备";
- onClick + promptAction:点击卡片弹出 Toast,让示例"可交互"——点任意商品都能得到反馈,直观证明每个网格单元都是独立可点的;
- 纯色背景 + 圆角 + 内边距:白卡片浮在浅灰页面上,是鸿蒙卡片设计的标准配方,视觉层级清晰。
9.2 页面骨架:Column + 标题 + 操作栏
把卡片放回页面。页面最外层是一个垂直排列的 Column,从上到下依次是:标题区 → 操作栏 → 网格 → 底部留白:
build() {
Column() {
// ===== 顶部标题区 =====
Text('Grid + scrollBar 大型网格滚动示例')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.width('100%')
.margin({ top: 12 })
Text('固定 height 使网格内部滚动 + scrollBar 显示滚动条')
.fontSize(13)
.fontColor('#999999')
.width('100%')
.margin({ top: 4, bottom: 10 })
// ===== 操作栏:演示编程式滚动 =====
Row({ space: 8 }) {
// 回到顶部:调用 scroller.scrollToIndex(0)
Button('⬆ 回到顶部')
.fontSize(13)
.layoutWeight(1)
.height(36)
.onClick(() => {
this.scroller.scrollToIndex(0);
})
// 翻到下一页:调用 scroller.scrollPage({ next: true })
Button('⤓ 下一页')
.fontSize(13)
.layoutWeight(1)
.height(36)
.onClick(() => {
this.scroller.scrollPage({ next: true });
})
}
.width('100%')
.margin({ bottom: 10 })
// ... 网格与底部留白
}
}
操作栏的 Row 里两个按钮都用 layoutWeight(1) 平分宽度,各占一半。两个按钮分别绑定 scrollToIndex(0) 与 scrollPage({ next: true })——这正是第七章讲的编程式滚动在实际页面里的落点。
9.3 核心网格区:三大技术点的最终落地
页面最关键的部分,就是标题和操作栏之下的那块网格。它把本文全部理论浓缩成了二十来行属性链:
// ===== 核心区域:Grid + scrollBar + height =====
// 关键布局说明:
// 1. 外层不再用 Scroll 包裹(区别于全页滚动场景),而是直接给 Grid 一个固定高度;
// 2. height('88%') 让网格区域占满剩余空间(高度封顶),
// 当 48 个网格单元的总高度超过该高度时,Grid 自动进入内部滚动;
// 3. scrollBar(BarState.Auto) 让滚动条在滚动时自动出现,滚动停止后淡出,
// 用户能清晰看到当前浏览位置 —— 这就是 scrollBar 布局的核心价值。
Grid(this.scroller) {
// ForEach 遍历商品数据,每个元素生成一个 GridItem(网格单元)
ForEach(this.goodsList, (item: GridGoods) => {
GridItem() {
// 网格单元内放置商品卡片
this.GoodsCard(item)
}
}, (item: GridGoods) => item.id.toString()) // key 生成器:以 id 为唯一键,保证数据变更时精准刷新
}
// ====== 核心技术点 1:固定列数的列模板 ======
// '1fr 1fr 1fr 1fr':四个等宽列,比例均为 1
.columnsTemplate(this.columns)
// 列间距 10vp
.columnsGap(10)
// 行间距 10vp
.rowsGap(10)
.width('100%')
// ====== 核心技术点 2:固定高度 height ======
// 高度设为剩余空间的 88%(高度封顶),这是「网格内部滚动」的前提:
// - height 固定 → 内容超高 → Grid 自身滚动 + 出现滚动条
// - height 为 auto → 高度随内容撑开 → Grid 自身不滚动,滚动条无意义
.height('88%')
// ====== 核心技术点 3:滚动条 scrollBar ======
// BarState.Auto:内容超出时滚动条自动出现;BarState.On 始终显示;BarState.Off 隐藏
.scrollBar(BarState.Auto)
// 自定义滚动条宽度(默认 4vp),加宽后更直观
.scrollBarWidth(6)
// 自定义滚动条颜色
.scrollBarColor('#4A90D9')
// 边缘回弹效果,滚动到底部 / 顶部时有弹性手感
.edgeEffect(EdgeEffect.Spring)
把这一整段拆开看,每一块属性都在执行一个明确的职责:
Grid(this.scroller):网格本体,同时绑定滚动控制器,为编程式滚动留好"接口";ForEach+GridItem:数据到网格单元的映射。item.id.toString()作为 key,保证数据更新时只刷新变化的单元;columnsTemplate:4 列等宽,决定每行放几件商品;columnsGap/rowsGap:行列间距,让卡片之间留出呼吸感;height('88%'):本文的"开关",高度封顶后触发内部滚动;scrollBar+scrollBarWidth+scrollBarColor:滚动条的显隐策略与外观定制;edgeEffect:边界回弹,完善滚动手感。
至此,示例应用的核心布局已经完整。剩下的是收尾工作:补全页面外壳、注册路由、运行验证。
十、完整代码清单
以下是示例应用 entry/src/main/ets/pages/GridScrollBar.ets 的完整代码,可直接复制到工程中运行。文件头部大段注释即布局要点总结,代码内部按"数据模型 → 数据生成 → 卡片组件 → 页面主体"分段注释,便于对照前文理解:
/**
* GridScrollBar.ets
* 鸿蒙原生 ArkTS 布局方式:Grid + scrollBar 滚动条布局(Large Grid with ScrollBar)
*
* 应用场景:大型网格滚动显示(如商品瀑布流、股票行情、日历、图片列表等)
* 核心技术:Grid + scrollBar + height
*
* 布局要点:
* 1. Grid 是鸿蒙高性能网格容器,当「网格总高度 > 容器高度」时自动进入内部滚动状态;
* 2. 必须给 Grid 设置固定 height(如 '100%' 或具体 vp 值),容器高度封顶后才会产生滚动条;
* 若高度为 auto(随内容撑开),网格整体会超出屏幕且 Grid 自身不滚动 —— 这是本布局最核心的差异;
* 3. scrollBar(BarState.Auto / On / Off) 控制滚动条显隐:
* - BarState.Auto:内容超出时自动显示,滚动时出现、停止后淡出;
* - BarState.On :始终显示;
* - BarState.Off :始终隐藏;
* 4. scrollBarWidth 自定义滚动条宽度;scrollBarColor 自定义滚动条颜色;
* 5. GridController 可用于编程式滚动:scrollToIndex 跳转到指定索引(如「回到顶部 / 下一屏」);
* 6. edgeEffect(EdgeEffect.Spring) 设置边缘回弹效果,提升滚动手感。
*/
import { promptAction } from '@kit.ArkUI';
// ---------- 数据模型 ----------
// 网格单元数据模型:用于大型商品网格场景
class GridGoods {
id: number; // 唯一标识(作为 ForEach 的 key,保证精确刷新)
name: string; // 商品名称
price: number; // 商品价格(数字便于演示排序)
color: string; // 占位色块颜色(实际项目此处应为商品图片资源)
icon: string; // 占位图标(用 emoji 模拟商品图,保证示例无需额外资源即可运行)
constructor(id: number, name: string, price: number, color: string, icon: string) {
this.id = id;
this.name = name;
this.price = price;
this.color = color;
this.icon = icon;
}
}
@Entry
@Component
struct GridScrollBar {
// ---------- 页面状态数据 ----------
// 大型网格数据集:动态生成 48 件商品(4 列 × 12 行),高度远超一屏,用于演示网格内部滚动
@State goodsList: GridGoods[] = this.generateGoods(48);
// 网格列数:'1fr 1fr 1fr 1fr' 表示均分为 4 列
private columns: string = '1fr 1fr 1fr 1fr';
// 网格控制器:用于编程式滚动(回到顶部、翻页)
private scroller: Scroller = new Scroller();
// ---------- 数据生成函数 ----------
// 动态生成 n 件商品,模拟真实场景下从网络 / 数据库加载出的大量数据
generateGoods(count: number): GridGoods[] {
const names: string[] = ['无线耳机', '智能手表', '蓝牙音箱', '机械键盘', '电竞鼠标', '4K显示器',
'充电宝', '手机支架', '降噪耳麦', '平板电脑', '智能台灯', '空气净化器'];
const colors: string[] = ['#4A90D9', '#50B5A9', '#F5A623', '#7B6FD0', '#E8634A', '#3BB0DB',
'#8B9D5A', '#C77B8E', '#5B8D6E', '#E67E22', '#16A085', '#8E44AD'];
const icons: string[] = ['🎧', '⌚', '🔊', '⌨️', '🖱️', '🖥️', '🔋', '📱', '🎙️', '📟', '💡', '🌀'];
const list: GridGoods[] = [];
for (let i = 0; i < count; i++) {
// 每个单元格取不同的名字 / 颜色 / 图标,并让价格随序号递增,方便肉眼确认网格在滚动
list.push(new GridGoods(
i,
`${names[i % names.length]} ${i + 1}号`,
99 + i * 17,
colors[i % colors.length],
icons[i % icons.length]
));
}
return list;
}
// ---------- 商品卡片(@Builder 复用) ----------
// @Builder 抽取可复用的 UI 片段:传入一个商品对象,即可生成一张网格卡片
@Builder
GoodsCard(item: GridGoods) {
Column() {
// 商品占位图:实际项目请替换为 Image($r('app.media.xxx'))
// 这里用圆角色块 + emoji 模拟商品图,保证示例无需额外图片资源即可运行
Stack() {
Text(item.icon)
.fontSize(34)
}
.width('100%')
.height(80)
.borderRadius(10)
.backgroundColor(item.color)
// 商品名称
Text(item.name)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ top: 6 })
.maxLines(1) // 单行显示,超长省略
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 商品价格
Text(`¥${item.price}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E64340')
.margin({ top: 2 })
}
.width('100%')
.padding(8)
.borderRadius(10)
.backgroundColor('#FFFFFF')
// 点击卡片弹出提示,直观验证每个网格单元都可交互
.onClick(() => {
promptAction.showToast({
message: `点击了:${item.name},价格 ¥${item.price}`,
duration: 1500
});
})
}
// ---------- 页面主体 ----------
build() {
Column() {
// ===== 顶部标题区 =====
Text('Grid + scrollBar 大型网格滚动示例')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.width('100%')
.margin({ top: 12 })
Text('固定 height 使网格内部滚动 + scrollBar 显示滚动条')
.fontSize(13)
.fontColor('#999999')
.width('100%')
.margin({ top: 4, bottom: 10 })
// ===== 操作栏:演示编程式滚动 =====
Row({ space: 8 }) {
// 回到顶部:调用 scroller.scrollToIndex(0)
Button('⬆ 回到顶部')
.fontSize(13)
.layoutWeight(1)
.height(36)
.onClick(() => {
this.scroller.scrollToIndex(0);
})
// 翻到下一页:调用 scroller.scrollPage({ next: true })
Button('⤓ 下一页')
.fontSize(13)
.layoutWeight(1)
.height(36)
.onClick(() => {
this.scroller.scrollPage({ next: true });
})
}
.width('100%')
.margin({ bottom: 10 })
// ===== 核心区域:Grid + scrollBar + height =====
// 关键布局说明:
// 1. 外层不再用 Scroll 包裹(区别于全页滚动场景),而是直接给 Grid 一个固定高度;
// 2. height('88%') 让网格区域占满剩余空间(高度封顶),
// 当 48 个网格单元的总高度超过该高度时,Grid 自动进入内部滚动;
// 3. scrollBar(BarState.Auto) 让滚动条在滚动时自动出现,滚动停止后淡出,
// 用户能清晰看到当前浏览位置 —— 这就是 scrollBar 布局的核心价值。
Grid(this.scroller) {
// ForEach 遍历商品数据,每个元素生成一个 GridItem(网格单元)
ForEach(this.goodsList, (item: GridGoods) => {
GridItem() {
// 网格单元内放置商品卡片
this.GoodsCard(item)
}
}, (item: GridGoods) => item.id.toString()) // key 生成器:以 id 为唯一键,保证数据变更时精准刷新
}
// ====== 核心技术点 1:固定列数的列模板 ======
// '1fr 1fr 1fr 1fr':四个等宽列,比例均为 1
.columnsTemplate(this.columns)
// 列间距 10vp
.columnsGap(10)
// 行间距 10vp
.rowsGap(10)
.width('100%')
// ====== 核心技术点 2:固定高度 height ======
// 高度设为剩余空间的 88%(高度封顶),这是「网格内部滚动」的前提:
// - height 固定 → 内容超高 → Grid 自身滚动 + 出现滚动条
// - height 为 auto → 高度随内容撑开 → Grid 自身不滚动,滚动条无意义
.height('88%')
// ====== 核心技术点 3:滚动条 scrollBar ======
// BarState.Auto:内容超出时滚动条自动出现;BarState.On 始终显示;BarState.Off 隐藏
.scrollBar(BarState.Auto)
// 自定义滚动条宽度(默认 4vp),加宽后更直观
.scrollBarWidth(6)
// 自定义滚动条颜色
.scrollBarColor('#4A90D9')
// 边缘回弹效果,滚动到底部 / 顶部时有弹性手感
.edgeEffect(EdgeEffect.Spring)
// 底部留白,避免内容贴边
Blank()
.height(8)
}
.width('100%')
.height('100%')
.padding({ left: 12, right: 12 })
// 页面浅灰背景,衬托白色卡片
.backgroundColor('#F2F3F5')
}
}
整份代码 208 行,无任何第三方依赖,除了 promptAction(系统提示能力)外只用到了 ArkUI 原生组件。把这份文件放进 pages/ 目录并注册路由后,即可在真机或模拟器上运行,直观看到"4 列网格 + 右侧滚动条 + 顶部编程式滚动按钮"的完整效果。
十一、页面注册与运行效果
代码写好了,还要让系统"认识"这个页面。在鸿蒙 Stage 模型中,页面通过路由表 main_pages.json 统一注册。
11.1 注册路由
打开 entry/src/main/resources/base/profile/main_pages.json,把新页面加进 src 数组:
{
"src": [
"pages/Index",
"pages/GridFixedColumns",
"pages/GridScrollBar"
]
}
注册完成后,页面可以通过两种方式展示:
- 设为启动页:把
pages/GridScrollBar挪到src数组首位,应用启动后直接进入该页面; - 路由跳转:在其他页面(如首页)通过
router.pushUrl({ url: 'pages/GridScrollBar' })跳转进入。
11.2 运行与观察要点
在 DevEco Studio 中点击 Run,选择模拟器或真机,应用启动后你应该依次观察到以下现象:
- 页面结构:顶部是标题与说明文字,其下是两个并排按钮,再往下是 4 列商品网格,浅灰背景上铺满白色圆角卡片;
- 网格排布:48 件商品按"先从左到右、再从上到下"的顺序填入 4 列网格,每行 4 件、共 12 行,与
columnsTemplate的声明完全一致; - 内部滚动:网格区域约一屏高、内容有 12 行,滑动时只有网格在动,顶部的标题和按钮纹丝不动;
- 滚动条浮现:滑动时网格右侧浮现 6vp 宽的蓝色滚动条指示位置,停止约一秒后自动淡出;
- 边缘回弹:滚到顶部或底部继续拖拽,内容弹性拉出再弹回;
- 编程式滚动:点"回到顶部"平滑滚回第一件;点"下一页"整屏下翻,连续点击可一路翻到第 12 行。
11.3 常见问题速查
运行中如果遇到异常,优先对照这份排查表:
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 网格不滚动,滚动条不出现 | Grid 高度未固定(auto 撑开) | 给 Grid 设置固定 height |
| 页面整体跟着滚动 | 外层误用了 Scroll 包裹 |
去掉外层 Scroll,改由 Grid 自身滚动 |
| 滚动条一直显示或一直隐藏 | BarState 选错 |
按需求改用 Auto / On / Off |
| 滚动条太细看不见 | 默认宽度 4vp 过窄 | 用 scrollBarWidth 加宽 |
| 点按钮没反应 | 控制器未与 Grid 绑定 | 确认是 Grid(this.scroller) 而非 Grid() |
十二、进阶话题:性能优化与 LazyForEach 懒加载
示例用了 48 件商品,滚动已经非常流畅。但真实业务的数据量可能是 480 件、4800 件甚至更多。这一章聊聊大型网格的性能优化,以及把示例升级到"生产级"的关键一步。
12.1 数据量大了会发生什么
ForEach 是"全量渲染"的:无论 48 件还是 4800 件,它都会把数据全部转换为组件树。数据量小时毫无压力,但网格单元变多后,两个问题会逐渐显现:
- 首帧变慢:4800 个 GridItem 及其内部卡片都要在首帧创建,启动时间显著拉长;
- 内存膨胀:滚出屏幕的单元也不会销毁,内存占用随数据量线性增长。
12.2 LazyForEach:按需懒加载
解决之道是 LazyForEach——Grid 和 List 等滚动容器支持的懒加载接口。它只创建当前可视区域及其附近的网格单元,滚出屏幕的单元会被回收,内存占用与数据总量解耦:
// 懒加载场景示意:用 LazyForEach 替代 ForEach
LazyForEach(this.dataSource, (item: GridGoods) => {
GridItem() {
this.GoodsCard(item)
}
}, (item: GridGoods) => item.id.toString())
使用 LazyForEach 需要把数据包装成 IDataSource 实现类,提供 totalCount、getData、registerDataChangeListener 等方法,让框架知道"一共有多少条、第 n 条是什么"。这也是 ArkUI 性能优化的核心手段:滚动容器 + 懒加载 = 海量数据也能流畅滚动。
12.3 其他性能要点
除了懒加载,大型网格还有几个值得注意的优化方向:
- 图片懒加载与占位:真实项目中网格单元里是网络图片,应使用
Image的懒加载能力与占位图,避免图片解码阻塞滚动; - 避免不必要的状态刷新:
@State装饰的对象粒度要小,只把真正变化的数据放入状态管理,防止整个网格因一次局部变化而重建; - 固定网格单元高度:高度固定的 GridItem 可以让框架更精确地预计算滚动位置,减少布局抖动;
cachedCount预热:适当设置缓存数量,让即将进入视口的单元提前创建,滑动时更跟手。
十三、总结
回到文章开头的问题:当数据量突破一屏,滚动怎么做才专业?本文给出的答案是 Grid + scrollBar + height 这一套组合拳。它的本质只有一句话——用固定高度封顶容器,让 Grid 在内部滚动,再用滚动条把滚动状态呈现给用户。
回顾全文,我们从四个层面完成了对这个布局方式的完整拆解:
第一层:布局哲学。 整页滚动与容器内部滚动是两种截然不同的交互形态。数据量小、结构简单时选前者;数据量大、需要操作区常驻或进度可见时选后者。本文示例的选择,代表了大型网格浏览场景的行业共识。
第二层:核心技术。 height 是滚动的开关——高度封顶才产生滚动;scrollBar 是滚动的外显——BarState.Auto 让滚动条用时现身、闲时隐身,scrollBarWidth / scrollBarColor 定制外观;edgeEffect 完善边界手感。三者配合,滚动体验从"能用"升级为"好用"。
第三层:交互闭环。 Scroller 控制器把"手动滚动"延伸为"编程式滚动"——scrollToIndex 一键回到顶部,scrollPage 整屏翻页。操作栏固定在容器外,与内部滚动的网格各司其职,互不干扰。
第四层:工程落地。 数据模型、动态生成、@Builder 卡片复用、ForEach 唯一 key、路由注册、问题排查——从一段可以运行的原型代码,到可以支撑大规模数据的生产级方案(懒加载、缓存预热、图片优化),升级路径清晰可见。
更多推荐



所有评论(0)