跳转到内容

编程实验:开发商品列表页

本实验将基于 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 当前只是占位页面:

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export 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 表示该分类下的商品。

ListItemGroup 的基本写法
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 提供的滚动控制器,可以绑定到 ListScroll 等支持滚动的容器上,主动控制其滚动位置;也可以通过容器的 onScrollIndex 事件回调监听当前可见的起始/结束子项下标。

用法作用
new Scroller()创建一个滚动控制器实例
List({ scroller: this.xxxScroller }) { ... }把 Scroller 绑定到 List
this.xxxScroller.scrollToIndex(index)主动滚动到指定子项
.onScrollIndex((start, end) => { ... })当前可见子项范围发生变化时回调

本实验会使用两个独立的 Scroller:一个绑定在左侧分类列表,一个绑定在右侧商品列表,配合 scrollToIndexonScrollIndex 实现两侧联动。

目标:先不接入服务端接口,搭出「标题栏 + 内容区」的页面骨架,并用 setTimeout 模拟数据加载过程,完成 Loading、Empty、Error、Content 四种页面状态之间的切换;Content 状态下先用占位文字代替真实列表。

4.1 引入页面状态和通用组件

打开 src/main/ets/customer/goods/GoodsListView.ets,补充本步骤需要用到的 import。

entry/src/main/ets/customer/goods/GoodsListView.ets
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 定义页面状态变量

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
// 当前查看的商铺,在创建 GoodsListView 组件时初始化
storeData: StoreData = new StoreData()
// TODO: 使用 @State 定义 pageStatus,初始值设置为 PageStatus.Loading
build() {
...
}
}

4.3 编写模拟加载函数

新增 loadGoodsList() 方法。此时暂时不访问接口,只使用 setTimeout 模拟 1.5 秒的数据加载过程。

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
...
loadGoodsList() {
// TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading
// TODO: 使用 setTimeout 模拟 1.5 秒网络请求耗时
// TODO: 在 setTimeout 回调中,先模拟加载成功,将 pageStatus 切换为 PageStatus.Content
}
build() {
...
}
}

4.4 页面出现时触发加载

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
...
aboutToAppear() {
// TODO: 调用 loadGoodsList,进入页面后立即加载商品数据
}
build() {
...
}
}

4.5 搭建状态切换页面

将原来的占位布局替换为「标题栏 + 状态内容区」。标题栏通过 TitleBarView 显示当前商铺名称;内容区使用 Stack,根据 pageStatus 切换显示不同视图。

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export 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

entry/src/main/ets/customer/goods/GoodsListView.ets
setTimeout(() => {
// TODO: 临时切换为 PageStatus.Empty,验证空数据页面
// TODO: 临时切换为 PageStatus.Error,验证异常页面
// TODO: 验证完成后改回 PageStatus.Content
}, 1500)

✅ 预期效果:进入商品列表页后,标题栏显示当前商铺名称;页面下方先显示「正在加载商品列表」,1.5 秒后切换到内容占位区域;临时切换状态时,能看到空数据页或异常页,点击后重新进入加载过程。

Loading 状态
Content 状态(占位)
Empty 状态
Error 状态

目标:在 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.etsmodel/GoodsData.ets 已经预先完成了字段定义和 class-transformer 注解,本实验不需要再新建数据类。

5.2 实现 getGoodsListForCustomer 方法

打开 entry/src/main/ets/api/ServerApi.ets,可以看到模板工程已经预留好了方法签名,并提供了一个私有静态方法 json2ResponseSpecDataList 用于把 JSON 字符串解析成 Response<ArrayList<SpecData>>

entry/src/main/ets/api/ServerApi.ets(模板已提供)
// 将 JSON 字符串解析成 Response<ArrayList<SpecData>> 类型数据
private static json2ResponseSpecDataList(json: string): Response<ArrayList<SpecData>> {
...
}

接下来按下面的骨架实现 getGoodsListForCustomer。请直接调用 ServerApi.executeServerApi.json2ResponseSpecDataList,不需要自己重写 token 注入和 JSON 解析。

entry/src/main/ets/api/ServerApi.ets
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:

entry/src/main/ets/customer/goods/GoodsListView.ets
import { StoreData } from '../../model/StoreData'
import { ServerApi } from '../../api/ServerApi'
import { EmptyView } from '../../common/widget/EmptyView'

loadGoodsList 改造为以下结构:

entry/src/main/ets/customer/goods/GoodsListView.ets
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 调用是否正确。

✅ 预期效果:日志面板能看到接口返回的 codemessage、分类数量以及每个分类下的商品数量;页面在加载完成后会显示 Content 占位文本。

商品接口调试日志输出

目标:把 getGoodsListForCustomer 接口数据接入 GoodsListView,根据接口结果驱动 Step 4 中已经搭好的状态流转,并把数据存到合适的数据源中以备后续渲染使用。

6.1 商品分类数据的存储方式

服务端返回的数据本身就是「分类数组 → 每个分类挂着商品数组」的两层结构,例如:

[
{ id: 3, name: '肉类', goodsList: [叉烧, 烧鹅, ...] },
{ id: 7, name: '海鲜', goodsList: [虾, 蟹, ...] },
{ id: 326, name: '饮料', goodsList: [可乐, 雪碧, ...] }
]

提示:实际接口返回中的字段名是 specsIdspecsName。模板工程的 SpecData 使用了 class-transformer@Expose 装饰器把它们重命名为 idname,所以代码中使用 spec.id / spec.name 读取。

SpecData 类已经把内部商品 goodsList: GoodsData[] 作为字段一起反序列化好了,因此页面只需要维护一个对应分类列表的数据源即可,内层商品直接通过 spec.goodsList 读取,不需要为每个分类再单独维护一个数据源。

页面状态结构如下:

GoodsListView
└── specDataSource: BasicDataSource<SpecData> ← 唯一的状态变量
├── SpecData(肉类)
│ └── goodsList: [叉烧, 烧鹅, ...] ← 直接读字段,渲染时用 ForEach
├── SpecData(海鲜)
│ └── goodsList: [虾, 蟹, ...]
└── SpecData(饮料)
└── goodsList: [可乐, 雪碧, ...]

对应到右侧商品列表的渲染:

渲染层级数据来源ArkUI 用法
外层(按分类分组)this.specDataSourceLazyForEach(this.specDataSource, ...)
内层(分组内的商品)spec.goodsListForEach(spec.goodsList, ...)

单个分类下的商品通常只有十几条,ArkUI 中使用 ForEach 渲染没有性能压力。

6.2 定义商品分类数据源

打开 GoodsListView.ets,先补充数据源相关 import。

entry/src/main/ets/customer/goods/GoodsListView.ets
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 中新增数据源状态变量。

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export 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 调试代码可保留也可删除。

entry/src/main/ets/customer/goods/GoodsListView.ets
async loadGoodsList() {
// TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading
// TODO: 调用 ServerApi.getGoodsListForCustomer(this.storeData.id) 拿到 rsp
// TODO: 5.3 中的 console.log 调试代码可保留也可删除
// TODO: 根据接口返回结果填充分类数据源 specDataSource
}

提示:Response<ArrayList<SpecData>> 中的 dataArrayList<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,写入组件骨架。

entry/src/main/ets/customer/goods/DoubleListComponent.ets
import { SpecData } from '../../model/SpecData'
import { BasicDataSource } from '../../common/datsource/BasicDataSource'
@Component
export 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

entry/src/main/ets/customer/goods/GoodsListView.ets
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

entry/src/main/ets/customer/goods/GoodsListView.ets
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 构建函数,根据当前下标判断是否选中,控制背景色和文字颜色。

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export 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

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Builder
SpecListBuilder() {
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
  • ListItemGroupheader 参数:传入分组标题(分类名称)
  • ListItemGroup 内部:渲染该分类下的商品

思考一下:内层渲染商品时,应该用 ForEach 还是 LazyForEach

LazyForEach 必须传入实现了 IDataSource 接口的数据源对象。Step 6 中我们采用了「方案 A」,没有为每个分类单独维护 BasicDataSource<GoodsData>,而是直接读取 spec.goodsList(普通数组)。因此内层只能使用 ForEach。单个分类下商品数量有限,性能可以接受。

9.2 编写 GoodsListBuilder

DoubleListComponent 中先 import 商品数据类,然后新增 GoodsListBuilder 构建函数。

entry/src/main/ets/customer/goods/DoubleListComponent.ets
import { SpecData } from '../../model/SpecData'
import { GoodsData } from '../../model/GoodsData'
import { BasicDataSource } from '../../common/datsource/BasicDataSource'
entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export 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 的右侧。

entry/src/main/ets/customer/goods/DoubleListComponent.ets
build() {
Row() {
// TODO: 左侧分类列表
this.SpecListBuilder()
// TODO: 删除“右侧商品列表区域暂时不实现”注释
// TODO: 调用 this.GoodsListBuilder() 显示右侧商品列表
}
.size({ width: '100%', height: '100%' })
}

✅ 预期效果:右侧出现一个分组列表,每个分组顶部是分类名称(白底文字),下方按行显示该分类的商品名称;上下滚动时,分组标题会吸顶。

商品分组列表(占位文本)

目标:把分组标题和商品小项的占位文本替换为完整的 UI 样式。

10.1 编写 GroupHeadBuilder

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export 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

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Builder
GoodsItemBuilder(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

entry/src/main/ets/customer/goods/DoubleListComponent.ets
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

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export 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 点击分类时滚动右侧商品列表

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Builder
SpecItemBuilder(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) => { ... }) 回调,每当当前可见的子项范围发生变化时都会触发,参数 startend 分别是可见区域的起始和结束子项下标。本步骤的反向联动思路是:

  1. 监听右侧商品 ListonScrollIndex,回调中得到的 start 就是当前最顶部的分类下标
  2. 如果 startcurrentSpecIndex 不同,更新 currentSpecIndex,让左侧分类的选中态跟随变化
  3. 同时监听左侧分类 ListonScrollIndex,记录左侧当前可见的分类下标范围
  4. 当新选中的分类不在左侧可见范围内时,调用 specScroller.scrollToIndex(currentSpecIndex) 把它滚到可见区域

为了表达「下标范围」,模板工程在 customer/goods/model/Range.ets 提供了一个 Range 工具类,包含 setRange(start, end)include(value) 方法。

12.2 监听左侧可见范围

先 import Range,并新增可见范围状态。

entry/src/main/ets/customer/goods/DoubleListComponent.ets
import { GoodsData } from '../../model/GoodsData'
import { Range } from './model/Range'
import { BasicDataSource } from '../../common/datsource/BasicDataSource'
entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export 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 监听右侧滚动并联动左侧

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Builder
GoodsListBuilder() {
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. 点击左侧分类,右侧商品列表能滚动到对应分类
  3. 手动滑动右侧商品列表,左侧分类的选中态跟随变化;切换到原本不可见的分类时,左侧也能自动滚到对应位置

提交要求:

  • 提交一份录屏视频(建议时长 1~2 分钟,文件大小不超过 50MB)
  • 提交本实验完成后的工程代码(zip 压缩包,删除 node_modulesbuildoh_modules 等目录)

评分标准:

评分项要求
商品接口实现getGoodsListForCustomer 能正确发起请求并解析数据,日志中可见返回的分类与商品
页面状态切换加载中、加载失败、空数据、有数据四种状态的视图均能按预期出现
左侧分类列表UI 还原准确,能正确显示选中态,点击切换选中态生效
右侧商品分组列表UI 还原准确,分组头能吸顶展示
点击分类联动商品点击左侧任意分类,右侧能滚动到对应分组
滚动商品反向联动分类滚动右侧时左侧选中态跟随变化;选中分类不在可见范围时左侧能自动滚动