编程实验:开发商品列表页
本实验将基于 06_goodslist 模板工程,完成鸿蒙点餐 APP 中点击商铺后的「商品列表页」开发。最终页面采用左右双列表布局:左侧是商品分类列表,右侧是按分类分组展示的商品列表,两侧支持双向联动。
本实验主要完成以下任务:
- 搭建商品列表页的页面骨架与 Loading、空数据、异常、内容四种状态流转
- 在
ServerApi中封装根据商铺 ID 获取商品分类与商品的接口 - 设计
GoodsListView的数据存储结构,把接口数据接入页面 - 新建
DoubleListComponent组件实现左侧分类列表与右侧分组商品列表 - 通过
Scroller实现「点击分类滚动到对应商品分组」与「滚动商品自动选中分类」的双向联动
最终效果
本实验涉及的知识点
| 知识点 | 使用场景 |
|---|---|
Stack | 在内容区按页面状态切换 Loading、空数据、异常和内容视图 |
PageStatus | 记录当前页面处于加载中、显示内容、空数据或异常状态 |
List + ListItemGroup | 在右侧用二维列表表达「分类 → 商品」的分组结构 |
sticky: StickyStyle.Header | 让分组标题在滚动时吸顶 |
LazyForEach + BasicDataSource | 渲染左侧分类列表与右侧分组列表 |
ForEach | 在每个 ListItemGroup 内部渲染该分类下的商品 |
Scroller + scrollToIndex | 控制左右两个 List 的滚动位置 |
onScrollIndex | 监听商品列表当前可见的起始分组,实现反向联动 |
页面状态切换策略
| 场景 | 页面状态 | 页面表现 |
|---|---|---|
| 首次进入页面 | PageStatus.Loading | 显示加载中视图 |
| 加载失败 | PageStatus.Error | 显示异常视图,点击后重新加载 |
| 加载成功但没有数据 | PageStatus.Empty | 显示空数据视图,点击后重新加载 |
| 加载成功且有数据 | PageStatus.Content | 显示双列表 |
目标:导入并运行模板工程,明确本实验需要修改和新建的文件。
2.1 获取模板工程
模板工程仓库:https://cnb.cool/sziit-coding/harmony-coding/06_goodslist
请将该仓库克隆到本地后,使用 DevEco Studio 打开。
2.2 认识模板工程当前状态
模板工程已经完成启动页、登录页、首页 Tab 框架以及商铺列表页。点击商铺列表中的某个商铺,会跳转到 pages/GoodsPage.ets,该页面通过路由参数获取 StoreData 并传给 GoodsListView。
GoodsListView.ets 当前只是占位页面:
@Componentexport struct GoodsListView { // 当前查看的商铺,在创建 GoodsListView 组件时初始化 storeData: StoreData = new StoreData()
build() { Column() { Text('商品列表页').fontSize(40).fontWeight(FontWeight.Bold) } .justifyContent(FlexAlign.Center) .size({ width: '100%', height: '100%' }) }}api/ServerApi.ets 中已经预留了 getGoodsListForCustomer 接口方法,但函数体仅有 // todo 注释,需要在本实验中完成实现。
2.3 本实验涉及的文件
| 文件路径 | 操作 | 作用 |
|---|---|---|
entry/src/main/ets/api/ServerApi.ets | 修改 | 实现 getGoodsListForCustomer 接口请求 |
entry/src/main/ets/customer/goods/GoodsListView.ets | 修改 | 商品列表页主组件,负责页面骨架、状态流转、数据加载 |
entry/src/main/ets/customer/goods/DoubleListComponent.ets | 新建 | 双列表组件,承载分类列表、商品分组列表与联动逻辑 |
✅ 预期效果:运行模板工程并完成登录后,进入首页第一个 Tab,点击任意商铺进入商品列表页,页面中间显示「商品列表页」文字。
目标:理解本实验会用到的分组列表、粘性头部和滚动控制机制。
3.1 List + ListItemGroup 二维列表
ListItemGroup 用于在 List 内表达「分组」结构。每个 ListItemGroup 由一个可选的头部组件和若干 ListItem 子项组成。本实验的右侧商品列表会用每个 ListItemGroup 表示一个商品分类,组内 ListItem 表示该分类下的商品。
List() { ListItemGroup({ header: this.GroupHeadBuilder(spec) }) { ForEach(spec.goodsList, (goods: GoodsData) => { ListItem() { // TODO: 渲染单个商品 } }, (goods: GoodsData) => goods.id.toString()) }}3.2 sticky 粘性头部
List 组件的 sticky 属性配合 ListItemGroup 使用,可以让分组的头部在滚动时呈现吸顶效果,或让尾部呈现吸底效果。
| 取值 | 效果 |
|---|---|
StickyStyle.None | 不吸顶也不吸底(默认) |
StickyStyle.Header | 头部吸顶 |
StickyStyle.Footer | 尾部吸底 |
List() { // ...}.sticky(StickyStyle.Header)3.3 Scroller 滚动控制
Scroller 是 ArkUI 提供的滚动控制器,可以绑定到 List、Scroll 等支持滚动的容器上,主动控制其滚动位置;也可以通过容器的 onScrollIndex 事件回调监听当前可见的起始/结束子项下标。
| 用法 | 作用 |
|---|---|
new Scroller() | 创建一个滚动控制器实例 |
List({ scroller: this.xxxScroller }) { ... } | 把 Scroller 绑定到 List |
this.xxxScroller.scrollToIndex(index) | 主动滚动到指定子项 |
.onScrollIndex((start, end) => { ... }) | 当前可见子项范围发生变化时回调 |
本实验会使用两个独立的 Scroller:一个绑定在左侧分类列表,一个绑定在右侧商品列表,配合 scrollToIndex 与 onScrollIndex 实现两侧联动。
目标:先不接入服务端接口,搭出「标题栏 + 内容区」的页面骨架,并用 setTimeout 模拟数据加载过程,完成 Loading、Empty、Error、Content 四种页面状态之间的切换;Content 状态下先用占位文字代替真实列表。
4.1 引入页面状态和通用组件
打开 src/main/ets/customer/goods/GoodsListView.ets,补充本步骤需要用到的 import。
import { StoreData } from '../../model/StoreData'import { EmptyView } from '../../common/widget/EmptyView'import { ErrorView } from '../../common/widget/ErrorView'import { LoadingView } from '../../common/widget/LoadingView'import { TitleBarView } from '../../common/widget/TitleBarView'import { PageStatus } from '../../utils/Constants'4.2 定义页面状态变量
@Componentexport struct GoodsListView { // 当前查看的商铺,在创建 GoodsListView 组件时初始化 storeData: StoreData = new StoreData() // TODO: 使用 @State 定义 pageStatus,初始值设置为 PageStatus.Loading
build() { ... }}4.3 编写模拟加载函数
新增 loadGoodsList() 方法。此时暂时不访问接口,只使用 setTimeout 模拟 1.5 秒的数据加载过程。
@Componentexport struct GoodsListView { ...
loadGoodsList() { // TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading // TODO: 使用 setTimeout 模拟 1.5 秒网络请求耗时 // TODO: 在 setTimeout 回调中,先模拟加载成功,将 pageStatus 切换为 PageStatus.Content }
build() { ... }}4.4 页面出现时触发加载
@Componentexport struct GoodsListView { ...
aboutToAppear() { // TODO: 调用 loadGoodsList,进入页面后立即加载商品数据 }
build() { ... }}4.5 搭建状态切换页面
将原来的占位布局替换为「标题栏 + 状态内容区」。标题栏通过 TitleBarView 显示当前商铺名称;内容区使用 Stack,根据 pageStatus 切换显示不同视图。
@Componentexport struct GoodsListView { ...
build() { Column() { // TODO: 使用 TitleBarView 显示页面标题 // · title: this.storeData.name // · backEnabled: true
Stack() { // TODO: Loading 状态显示 LoadingView,message 使用字符串资源 app.string.message_loading_goods_in_store // TODO: Empty 状态显示 EmptyView,message 使用字符串资源 app.string.message_empty_goods // TODO: Error 状态显示 ErrorView,message 使用字符串资源 app.string.message_error_loading_goods // TODO: Content 状态先显示一个居中的占位文本“商品列表区域 - 待实现”,fontSize 为 18,fontColor 为 black_60 } // TODO: 设置 Stack.width('100%'),并通过 layoutWeight(1) 占满标题栏下方剩余空间 } // TODO: 设置根 Column 的 size 为 { width: '100%', height: '100%' } }}4.6 验证不同页面状态
默认情况下,页面会先显示加载中视图,1.5 秒后显示「商品列表区域 - 待实现」。
验证空数据和异常状态时,可以临时修改模拟加载函数中最终赋值的 pageStatus:
setTimeout(() => { // TODO: 临时切换为 PageStatus.Empty,验证空数据页面 // TODO: 临时切换为 PageStatus.Error,验证异常页面 // TODO: 验证完成后改回 PageStatus.Content}, 1500)✅ 预期效果:进入商品列表页后,标题栏显示当前商铺名称;页面下方先显示「正在加载商品列表」,1.5 秒后切换到内容占位区域;临时切换状态时,能看到空数据页或异常页,点击后重新进入加载过程。
目标:在 ServerApi.getGoodsListForCustomer(storeId) 中完成商品分类与商品接口的对接。
5.1 查看商品列表接口
接口文档地址:https://docs.apipost.net/docs/detail/2c39ebb29c64000?target_id=4ed3cdd
本实验使用的获取商铺商品接口为:
GET https://api.food2.sziit.top/order-store/goods?storeId={商铺ID}接口成功时返回的数据按「分类 → 商品」的两层结构组织,每个分类内含 goodsList 数组:
{ "code": 200, "msg": "成功", "data": [ { "specsId": 3, "specsName": "肉类", "sort": 1, "goodsList": [ { "id": 161, "name": "叉烧", "image": "...", "price": 123 }, { "id": 164, "name": "烧鹅", "image": "...", "price": 144 } ] }, { "specsId": 326, "specsName": "饮料", "sort": 2, "goodsList": [] } ]}模板工程的 model/SpecData.ets 与 model/GoodsData.ets 已经预先完成了字段定义和 class-transformer 注解,本实验不需要再新建数据类。
5.2 实现 getGoodsListForCustomer 方法
打开 entry/src/main/ets/api/ServerApi.ets,可以看到模板工程已经预留好了方法签名,并提供了一个私有静态方法 json2ResponseSpecDataList 用于把 JSON 字符串解析成 Response<ArrayList<SpecData>>:
// 将 JSON 字符串解析成 Response<ArrayList<SpecData>> 类型数据private static json2ResponseSpecDataList(json: string): Response<ArrayList<SpecData>> { ...}接下来按下面的骨架实现 getGoodsListForCustomer。请直接调用 ServerApi.execute 与 ServerApi.json2ResponseSpecDataList,不需要自己重写 token 注入和 JSON 解析。
static async getGoodsListForCustomer(storeId: number): Promise<Response<ArrayList<SpecData>>> { // todo: 完成接口服务端接口对接 return new Response<ArrayList<SpecData>>(SpecData) // TODO: 使用 http.createHttp() 创建 request 对象 // TODO: 创建 HttpRequestOptions: // · method: http.RequestMethod.GET // · expectDataType: http.HttpDataType.STRING // · connectTimeout: 5000 // · readTimeout: 5000
try { // TODO: 调用 ServerApi.execute(request, url, options) 发起请求 // · url 形如 `${Constants.SERVER_HOST}/order-store/goods?storeId=${storeId}` // TODO: 将 rsp.result 强制转为 string,并交给 ServerApi.json2ResponseSpecDataList 解析后返回 } catch (e) { // TODO: 创建错误 Response,code 设置为 -1,message 设置为异常信息 // TODO: 返回错误 Response }}5.3 在 loadGoodsList 中调用接口并通过日志验证
本步骤把 Step 4 中 loadGoodsList 内的 setTimeout 模拟代码替换为对 getGoodsListForCustomer 的真实调用,并通过日志确认接口返回数据正确。本步骤暂不处理成功/失败/空数据等不同情况下的状态切换,统一将 pageStatus 切到 Content,完整的状态驱动逻辑在 Step 6 中补充。
在 entry/src/main/ets/customer/goods/GoodsListView.ets 中补充 ServerApi 的 import:
import { StoreData } from '../../model/StoreData'import { ServerApi } from '../../api/ServerApi'import { EmptyView } from '../../common/widget/EmptyView'将 loadGoodsList 改造为以下结构:
loadGoodsList() {async loadGoodsList() { // TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading // TODO: 删除 Step 4 中用 setTimeout 模拟加载的整段代码
// TODO: 调用 ServerApi.getGoodsListForCustomer(this.storeData.id) 拿到 rsp // TODO: 使用 console.log 打印 rsp.code、rsp.message // TODO: 使用 console.log 打印 rsp.data?.length(分类数量) // TODO: 遍历 rsp.data,打印每个分类的 name 和 goodsList.length // TODO: 如果拉取数据成功且商品数据不为空,将 pageStatus 切换为 PageStatus.Content,否则切换到 PageStatus.Empty。 // 如果接口请求失败(例如网络异常),将 pageStatus 切换到 PageStatus.Error}提示:
async关键字必须加,否则不能在函数体内await接口请求。
运行项目,进入任意商铺的商品列表页,在 DevEco Studio 底部的 Log 面板筛选 App,预期能看到类似如下日志输出:
code = 200, message = 成功分类数量 = 8分类: 肉类, 商品数 = 2分类: 饮料, 商品数 = 0分类: 海鲜, 商品数 = 5...如果输出 code = -1 或者 分类数量 = undefined,说明接口实现或数据解析存在问题,需要回到 5.2 检查 URL 拼接、http.HttpRequestOptions 设置、json2ResponseSpecDataList 调用是否正确。
✅ 预期效果:日志面板能看到接口返回的 code、message、分类数量以及每个分类下的商品数量;页面在加载完成后会显示 Content 占位文本。
目标:把 getGoodsListForCustomer 接口数据接入 GoodsListView,根据接口结果驱动 Step 4 中已经搭好的状态流转,并把数据存到合适的数据源中以备后续渲染使用。
6.1 商品分类数据的存储方式
服务端返回的数据本身就是「分类数组 → 每个分类挂着商品数组」的两层结构,例如:
[ { id: 3, name: '肉类', goodsList: [叉烧, 烧鹅, ...] }, { id: 7, name: '海鲜', goodsList: [虾, 蟹, ...] }, { id: 326, name: '饮料', goodsList: [可乐, 雪碧, ...] }]提示:实际接口返回中的字段名是
specsId、specsName。模板工程的SpecData使用了class-transformer的@Expose装饰器把它们重命名为id与name,所以代码中使用spec.id/spec.name读取。
SpecData 类已经把内部商品 goodsList: GoodsData[] 作为字段一起反序列化好了,因此页面只需要维护一个对应分类列表的数据源即可,内层商品直接通过 spec.goodsList 读取,不需要为每个分类再单独维护一个数据源。
页面状态结构如下:
GoodsListView└── specDataSource: BasicDataSource<SpecData> ← 唯一的状态变量 ├── SpecData(肉类) │ └── goodsList: [叉烧, 烧鹅, ...] ← 直接读字段,渲染时用 ForEach ├── SpecData(海鲜) │ └── goodsList: [虾, 蟹, ...] └── SpecData(饮料) └── goodsList: [可乐, 雪碧, ...]对应到右侧商品列表的渲染:
| 渲染层级 | 数据来源 | ArkUI 用法 |
|---|---|---|
| 外层(按分类分组) | this.specDataSource | LazyForEach(this.specDataSource, ...) |
| 内层(分组内的商品) | spec.goodsList | ForEach(spec.goodsList, ...) |
单个分类下的商品通常只有十几条,ArkUI 中使用
ForEach渲染没有性能压力。
6.2 定义商品分类数据源
打开 GoodsListView.ets,先补充数据源相关 import。
import { StoreData } from '../../model/StoreData'import { SpecData } from '../../model/SpecData'import { ServerApi } from '../../api/ServerApi'import { BasicDataSource } from '../../common/datsource/BasicDataSource'import { EmptyView } from '../../common/widget/EmptyView'接着在 GoodsListView 中新增数据源状态变量。
@Componentexport struct GoodsListView { // 当前查看的商铺,在创建 GoodsListView 组件时初始化 storeData: StoreData = new StoreData() // TODO: 保留 pageStatus 状态变量 // TODO: 使用 @State 定义 specDataSource,类型为 BasicDataSource<SpecData>,初始值为 new BasicDataSource()
...}这里需要使用
@State修饰符把specDataSource定义为状态变量,以便在后续UI开发时,可以将此对象传递给子组件。
6.3 补全状态驱动与数据写入逻辑
在 5.3 的接口调用基础上作增量改造,将「统一切为 Content」的临时处理替换为根据接口返回结果驱动状态机:
- 接口请求失败:切换到
Error - 接口成功但
data为空:切换到Empty - 接口成功且有数据:先过滤掉
goodsList为空的分类,再判断剩余分类是否为空,决定Empty还是Content,并把数据写入specDataSource
5.3 中的 console.log 调试代码可保留也可删除。
async loadGoodsList() { // TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading // TODO: 调用 ServerApi.getGoodsListForCustomer(this.storeData.id) 拿到 rsp // TODO: 5.3 中的 console.log 调试代码可保留也可删除
// TODO: 根据接口返回结果填充分类数据源 specDataSource}提示:
Response<ArrayList<SpecData>>中的data是ArrayList<SpecData>类型。需要把它转成SpecData[]再过滤,例如可以调用convertToArray()函数。
6.4 阶段验证
完成上述改造后,运行项目并进入商品列表页:
- 网络正常时:先显示「正在加载商品列表」,加载完成后显示「商品列表区域 - 待实现」占位文本
- 关闭设备网络后再次进入:会显示异常页,点击后重新进入加载过程
由于此时还没有把 specDataSource 渲染出来,Content 状态下页面仍是占位文本。可以临时在 setDataList 后加一行 console.log 打印 this.specDataSource.totalCount() 来确认数据已经正确写入。
✅ 预期效果:状态流转能根据真实接口返回结果切换;控制台中可以看到分类数量大于 0;页面 Content 区仍然是占位文本。
目标:新建 DoubleListComponent 组件,先把左侧分类列表用最简单的 Text 跑通;右侧商品列表区域暂时留白。
7.1 新建 DoubleListComponent.ets
在 entry/src/main/ets/customer/goods/ 目录下新建 DoubleListComponent.ets,写入组件骨架。
import { SpecData } from '../../model/SpecData'import { BasicDataSource } from '../../common/datsource/BasicDataSource'
@Componentexport struct DoubleListComponent { // TODO: 使用 @Link 接收来自父组件的 specDataSource,类型为 BasicDataSource<SpecData> // TODO: 使用 @State 定义 currentSpecIndex,类型 number,初始值为 0
@Builder SpecListBuilder() { List() { LazyForEach( // TODO: 参数一传入 this.specDataSource // TODO: 参数二中 item 类型为 SpecData,在 ListItem 中先使用 Text(item.name) 显示分类名称 // · Text 设置 width('100%')、padding(12)、fontSize(15)、textAlign(TextAlign.Center) // TODO: 参数三使用 item.id.toString() 作为唯一 key ) } // TODO: 设置 List 的 width(95)、height('100%')、backgroundColor 为 black_5 }
build() { Row() { // TODO: 调用 this.SpecListBuilder() 显示左侧分类列表 // TODO: 右侧商品列表区域暂时不实现 } .size({ width: '100%', height: '100%' }) }
}7.2 在 GoodsListView 中接入 DoubleListComponent
import { ServerApi } from '../../api/ServerApi'import { BasicDataSource } from '../../common/datsource/BasicDataSource'import { DoubleListComponent } from './DoubleListComponent'import { EmptyView } from '../../common/widget/EmptyView'把 Step 4 中 Content 状态下的占位文本替换为 DoubleListComponent。
Stack() { // TODO: Loading 状态显示 LoadingView // TODO: Empty 状态显示 EmptyView // TODO: Error 状态显示 ErrorView // TODO: 删除 Content 状态下的占位 Text // TODO: Content 状态显示 DoubleListComponent({ specDataSource: $specDataSource })}✅ 预期效果:进入商品列表页加载完成后,左侧出现一列分类名称(如「肉类」「饮料」等),右侧暂时是空白区域。
目标:把分类列表的每一项从简单 Text 替换为带选中态的小项,点击后能切换 currentSpecIndex。
8.1 编写 SpecItemBuilder
在 DoubleListComponent 中新增 SpecItemBuilder 构建函数,根据当前下标判断是否选中,控制背景色和文字颜色。
@Componentexport struct DoubleListComponent { ...
@Builder SpecItemBuilder(index: number, specData: SpecData, isLast: boolean) { Column() { // TODO: 显示分类名称 Text(specData.name) // · fontSize: 15 // · fontColor: 选中时使用 primary_color,未选中时使用 black_80 // · padding({ top: 14, bottom: 14, left: 6, right: 6 }) // · textAlign(TextAlign.Center) // · width('100%') // TODO: 设置 Text 背景:选中时使用 white,未选中时使用 transparent // TODO: onClick 中将 this.currentSpecIndex 修改为 index
// TODO: 当 isLast 为 true 时,追加一个 Blank().height(300),把最后一项顶到顶部,方便联动到底部分类 } // TODO: 设置 Column.width('100%') }}8.2 在 SpecListBuilder 中使用 SpecItemBuilder
@BuilderSpecListBuilder() { List() { LazyForEach( this.specDataSource, (item: SpecData, index: number) => { ListItem() { // TODO: 删除 Text(item.name) 临时文本 // TODO: 调用 this.SpecItemBuilder(index, item, index == this.specDataSource.totalCount() - 1) } }, (item: SpecData) => item.id.toString() ) } ...}✅ 预期效果:左侧分类列表小项变为带选中态的样式,默认第一个分类高亮(白色背景 + 主题色文字);点击其他分类时,选中态会跟随移动。
目标:在右侧用 List + ListItemGroup 把「分类 → 商品」两层结构跑通,先用占位文本展示分组标题与商品名称。
9.1 如何用 List 表达「分类 → 商品」两层结构?
服务端数据是「分类数组 → 每个分类下的商品数组」的两层结构。要在 ArkUI 中表达这种结构,最自然的方式是使用 List + ListItemGroup:
- 外层
List:使用LazyForEach(this.specDataSource, ...)渲染每个分类,每个分类对应一个ListItemGroup ListItemGroup的header参数:传入分组标题(分类名称)ListItemGroup内部:渲染该分类下的商品
思考一下:内层渲染商品时,应该用
ForEach还是LazyForEach?
LazyForEach必须传入实现了IDataSource接口的数据源对象。Step 6 中我们采用了「方案 A」,没有为每个分类单独维护BasicDataSource<GoodsData>,而是直接读取spec.goodsList(普通数组)。因此内层只能使用ForEach。单个分类下商品数量有限,性能可以接受。
9.2 编写 GoodsListBuilder
在 DoubleListComponent 中先 import 商品数据类,然后新增 GoodsListBuilder 构建函数。
import { SpecData } from '../../model/SpecData'import { GoodsData } from '../../model/GoodsData'import { BasicDataSource } from '../../common/datsource/BasicDataSource'@Componentexport struct DoubleListComponent { ...
@Builder GoodsListBuilder() { List() { LazyForEach( this.specDataSource, (spec: SpecData) => { ListItemGroup({ // TODO: header 传入一个临时分组头:Text(spec.name).padding(12).fontSize(16).backgroundColor(white).width('100%') }) { ForEach( spec.goodsList, (goods: GoodsData) => { ListItem() { // TODO: 先使用 Text(goods.name).padding(12) 显示商品名称 } }, (goods: GoodsData) => goods.id.toString() ) } }, (spec: SpecData) => spec.id.toString() ) } // TODO: 设置 List.layoutWeight(1)、height('100%')、backgroundColor 为 black_5 // TODO: 开启 sticky(StickyStyle.Header) }}9.3 在 build 中显示商品列表
把 GoodsListBuilder 加入到 Row 内,放在 SpecListBuilder 的右侧。
build() { Row() { // TODO: 左侧分类列表 this.SpecListBuilder() // TODO: 删除“右侧商品列表区域暂时不实现”注释 // TODO: 调用 this.GoodsListBuilder() 显示右侧商品列表 } .size({ width: '100%', height: '100%' })}✅ 预期效果:右侧出现一个分组列表,每个分组顶部是分类名称(白底文字),下方按行显示该分类的商品名称;上下滚动时,分组标题会吸顶。
目标:把分组标题和商品小项的占位文本替换为完整的 UI 样式。
10.1 编写 GroupHeadBuilder
@Componentexport struct DoubleListComponent { ...
@Builder GroupHeadBuilder(specData: SpecData) { Row() { // TODO: 显示分类名称 Text(specData.name) // · fontSize: 15,fontWeight: FontWeight.Bold,fontColor 使用 black_80 } // TODO: 设置 Row.width('100%')、padding({ left: 12, top: 8, bottom: 8 })、backgroundColor 使用 #E6E8EE }}10.2 编写 GoodsItemBuilder
@BuilderGoodsItemBuilder(goods: GoodsData, isLast: boolean) { Column() { Row() { // TODO: 商品图片 Image(goods.imageUrl) // · alt: $r('app.media.img_empty') // · size: { width: 80, height: 80 } // · borderRadius: 6 // · backgroundColor 使用 black_5
Column() { // TODO: 商品名称 Text(goods.name),fontSize: 16,fontWeight: FontWeight.Medium,maxLines: 1 // TODO: 商品描述 Text(goods.goodsDesc),margin({ top: 6 }),fontSize: 13,fontColor 使用 black_60,maxLines: 1 // TODO: 月销 Text(goods.salesInMonth),margin({ top: 6 }),fontSize: 13,fontColor 使用 black_60 // TODO: 价格 Text(`¥${goods.price}`),margin({ top: 6 }),fontSize: 16,fontColor 使用 primary_color } .layoutWeight(1) .alignItems(HorizontalAlign.Start) .margin({ left: 10, right: 10 }) } .width('100%') .padding({ left: 12, right: 12, top: 12, bottom: 12 }) .backgroundColor(Color.White)
// TODO: 当 isLast 为 true 时,追加一个 Blank().height(300),方便最后一个分类滚动到顶部 }}10.3 在 GoodsListBuilder 中使用新的 Builder
LazyForEach( this.specDataSource, (spec: SpecData, specIndex: number) => { ListItemGroup({ // TODO: 删除临时分组头 Text header: this.GroupHeadBuilder(spec) }) { ForEach( spec.goodsList, (goods: GoodsData, goodsIndex: number) => { ListItem() { // TODO: 删除 Text(goods.name) 临时文本 this.GoodsItemBuilder( goods, specIndex == this.specDataSource.totalCount() - 1 && goodsIndex == spec.goodsList.length - 1 ) } }, (goods: GoodsData) => goods.id.toString() ) } }, (spec: SpecData) => spec.id.toString())✅ 预期效果:右侧商品列表显示出完整的商品小项(图片、名称、描述、月销、价格),分组头部带浅灰背景且能吸顶。
目标:点击左侧分类时,右侧商品列表自动滚动到对应的分类分组。
11.1 创建并绑定两个 Scroller
在 DoubleListComponent 中声明两个 Scroller,分别绑定到左侧分类 List 和右侧商品 List。
@Componentexport struct DoubleListComponent { // TODO: 保留 specDataSource、currentSpecIndex // TODO: 新增 specScroller,类型为 Scroller,初始值为 new Scroller() // TODO: 新增 goodsScroller,类型为 Scroller,初始值为 new Scroller()
...
@Builder SpecListBuilder() { List({ scroller: this.specScroller }) { ... } ... }
@Builder GoodsListBuilder() { List({ scroller: this.goodsScroller }) { ... } ... }}11.2 点击分类时滚动右侧商品列表
@BuilderSpecItemBuilder(index: number, specData: SpecData, isLast: boolean) { Column() { Text(specData.name) ... .onClick(() => { this.currentSpecIndex = index // TODO: 调用 this.goodsScroller.scrollToIndex(index),让右侧商品列表滚动到对应分组 }) } ...
}✅ 预期效果:点击左侧任意分类时,右侧商品列表会自动滚动到对应的分类分组。
目标:当用户手动滚动右侧商品列表时,左侧分类的选中态自动跟随当前可见的分组变化;如果当前选中的分类不在可见范围内,左侧分类列表也要滚动过去。
12.1 如何让左侧选中态跟随右侧滚动?
List 组件提供 onScrollIndex((start, end) => { ... }) 回调,每当当前可见的子项范围发生变化时都会触发,参数 start 和 end 分别是可见区域的起始和结束子项下标。本步骤的反向联动思路是:
- 监听右侧商品
List的onScrollIndex,回调中得到的start就是当前最顶部的分类下标 - 如果
start与currentSpecIndex不同,更新currentSpecIndex,让左侧分类的选中态跟随变化 - 同时监听左侧分类
List的onScrollIndex,记录左侧当前可见的分类下标范围 - 当新选中的分类不在左侧可见范围内时,调用
specScroller.scrollToIndex(currentSpecIndex)把它滚到可见区域
为了表达「下标范围」,模板工程在 customer/goods/model/Range.ets 提供了一个 Range 工具类,包含 setRange(start, end) 与 include(value) 方法。
12.2 监听左侧可见范围
先 import Range,并新增可见范围状态。
import { GoodsData } from '../../model/GoodsData'import { Range } from './model/Range'import { BasicDataSource } from '../../common/datsource/BasicDataSource'@Componentexport struct DoubleListComponent { // TODO: 保留 specDataSource、currentSpecIndex、specScroller、goodsScroller // TODO: 新增 specVisibleRange,类型为 Range,初始值为 new Range()
...
@Builder SpecListBuilder() { List({ scroller: this.specScroller }) { ... } ... .onScrollIndex((start: number, end: number) => { // TODO: 调用 this.specVisibleRange.setRange(start, end) }) }}12.3 监听右侧滚动并联动左侧
@BuilderGoodsListBuilder() { List({ scroller: this.goodsScroller }) { ... } ... .onScrollIndex((start: number, end: number) => { // TODO: 如果 start == this.currentSpecIndex,则不需要处理,return // TODO: 否则将 this.currentSpecIndex 更新为 start // TODO: 如果 this.specVisibleRange.include(start) == false, // 调用 this.specScroller.scrollToIndex(start) 让左侧分类滚动到可见 })}✅ 预期效果:手动滚动右侧商品列表时,左侧分类的选中态会跟着当前置顶的分组变化;当新选中的分类原本不在左侧可见范围内时,左侧分类列表也会自动滚动到该分类。点击左侧分类与滚动右侧列表两个方向的联动都能正常工作。
录制屏幕演示视频,证明你已经完成本实验:
- 进入商品列表页时正确显示加载中、加载失败、空数据、有数据四种页面状态(可通过断网或临时修改代码触发空数据/异常)
- 点击左侧分类,右侧商品列表能滚动到对应分类
- 手动滑动右侧商品列表,左侧分类的选中态跟随变化;切换到原本不可见的分类时,左侧也能自动滚到对应位置
提交要求:
- 提交一份录屏视频(建议时长 1~2 分钟,文件大小不超过 50MB)
- 提交本实验完成后的工程代码(zip 压缩包,删除
node_modules、build、oh_modules等目录)
评分标准:
| 评分项 | 要求 |
|---|---|
| 商品接口实现 | getGoodsListForCustomer 能正确发起请求并解析数据,日志中可见返回的分类与商品 |
| 页面状态切换 | 加载中、加载失败、空数据、有数据四种状态的视图均能按预期出现 |
| 左侧分类列表 | UI 还原准确,能正确显示选中态,点击切换选中态生效 |
| 右侧商品分组列表 | UI 还原准确,分组头能吸顶展示 |
| 点击分类联动商品 | 点击左侧任意分类,右侧能滚动到对应分组 |
| 滚动商品反向联动分类 | 滚动右侧时左侧选中态跟随变化;选中分类不在可见范围时左侧能自动滚动 |