鸿蒙 ArkTS 布局实战:Grid + scrollBar 大型网格滚动——海量数据的网格化浏览之道

本文基于 HarmonyOS NEXT 6.1.1(API 24)与 DevEco Studio 开发环境,以一个可直接运行的 ArkTS 示例应用为载体,深入讲解鸿蒙原生布局体系中"Grid + scrollBar 滚动条布局"的完整用法:从"整页滚动与容器内滚动"的本质区别、固定 height 的布局哲学,到滚动条的显隐控制与编程式滚动,再到一个 48 件商品的大型网格的真实落地实现,并给出全部可运行代码与详细中文注释。

目录

  1. 引言:当数据量突破一屏,滚动就成了刚需
  2. 环境准备:DevEco Studio 与工程创建
  3. 布局选型:整页滚动,还是网格内部滚动?
  4. Grid 核心概念回顾:columnsTemplate、fr 单位与 GridItem
  5. 核心技术点一:固定 height——网格内部滚动的开关
  6. 核心技术点二:scrollBar——滚动条的显隐与样式控制
  7. 核心技术点三:Scroller 控制器——编程式滚动
  8. 实战解析:大型商品网格的数据模型与动态生成
  9. 页面构建详解:从卡片组件到完整布局
  10. 完整代码清单
  11. 页面注册与运行效果
  12. 进阶话题:性能优化与 LazyForEach 懒加载
  13. 总结

一、引言:当数据量突破一屏,滚动就成了刚需

上一篇文章我们聊了 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,确认 compatibleSdkVersiontargetSdkVersion 与 HarmonyOS NEXT 6.1.1(API 24)相匹配。不同的 API 版本决定了 ArkUI 组件的可用属性集合,本文用到的 columnsTemplatescrollBarscrollBarWidthscrollBarColoredgeEffect 以及 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:唯一主键,也是 ForEach key 生成器的核心——数据增删改时,框架能精确定位到变化的网格单元,实现最小化刷新,而不是整个网格重建;
  • 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;
}

这个函数有两个精心设计的细节:

  1. 取模轮换i % names.length 让 12 种名字、12 种颜色、12 种图标循环使用,48 件商品里每种出现 4 次,视觉上"丰富但不乱";
  2. 价格递增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)→ 商品名称 → 商品价格。三个容易被忽略的细节:

  1. maxLines + textOverflow:名称限制单行,超出部分以省略号结尾。网格单元宽度有限,商品名一长就会被挤变形,这两行属性是网格卡片的"标配防爆装备";
  2. onClick + promptAction:点击卡片弹出 Toast,让示例"可交互"——点任意商品都能得到反馈,直观证明每个网格单元都是独立可点的;
  3. 纯色背景 + 圆角 + 内边距:白卡片浮在浅灰页面上,是鸿蒙卡片设计的标准配方,视觉层级清晰。

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)

把这一整段拆开看,每一块属性都在执行一个明确的职责:

  1. Grid(this.scroller):网格本体,同时绑定滚动控制器,为编程式滚动留好"接口";
  2. ForEach + GridItem:数据到网格单元的映射。item.id.toString() 作为 key,保证数据更新时只刷新变化的单元;
  3. columnsTemplate:4 列等宽,决定每行放几件商品;
  4. columnsGap / rowsGap:行列间距,让卡片之间留出呼吸感;
  5. height('88%'):本文的"开关",高度封顶后触发内部滚动;
  6. scrollBar + scrollBarWidth + scrollBarColor:滚动条的显隐策略与外观定制;
  7. 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,选择模拟器或真机,应用启动后你应该依次观察到以下现象:

  1. 页面结构:顶部是标题与说明文字,其下是两个并排按钮,再往下是 4 列商品网格,浅灰背景上铺满白色圆角卡片;
  2. 网格排布:48 件商品按"先从左到右、再从上到下"的顺序填入 4 列网格,每行 4 件、共 12 行,与 columnsTemplate 的声明完全一致;
  3. 内部滚动:网格区域约一屏高、内容有 12 行,滑动时只有网格在动,顶部的标题和按钮纹丝不动;
  4. 滚动条浮现:滑动时网格右侧浮现 6vp 宽的蓝色滚动条指示位置,停止约一秒后自动淡出;
  5. 边缘回弹:滚到顶部或底部继续拖拽,内容弹性拉出再弹回;
  6. 编程式滚动:点"回到顶部"平滑滚回第一件;点"下一页"整屏下翻,连续点击可一路翻到第 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 实现类,提供 totalCountgetDataregisterDataChangeListener 等方法,让框架知道"一共有多少条、第 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、路由注册、问题排查——从一段可以运行的原型代码,到可以支撑大规模数据的生产级方案(懒加载、缓存预热、图片优化),升级路径清晰可见。
在这里插入图片描述

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐