HarmonyOS用List组件搭一个超顺滑的商品列表-下拉刷新懒加载全都有
先看// 千分比布局// 字号// 间距// 图标尺寸// 列表项间距// 导航标题// 下拉刷新偏移量阈值// 刷新动画时长// 数据倍数和上限// 最大行数看到没?是下拉刷新的触发阈值(100vp),是刷新提示的显示时长(1.5秒),是懒加载的数据上限(12条)。把这些值抽成常量,改的时候一处改全,不用满世界找"魔法数字"。到这里,我们已经完成了整个商品列表页面的开发。Scroll + Lis
HarmonyOS 用 List 组件搭一个超顺滑的商品列表:下拉刷新、懒加载全都有
写在前面:商品列表是电商 App 的"门面"
做过电商类应用的朋友都知道,商品列表页面绝对是整个 App 里最核心、用户停留时间最长的页面之一。一个丝滑的商品列表——支持下拉刷新、数据懒加载、到底提示——能让用户体验直接上一个台阶。
今天我们要用 HarmonyOS 的 ArkUI 来实现一个完整的商品列表页面,核心用到的组件有三个:
- Scroll —— 外层滚动容器,负责处理下拉刷新手势和回弹效果
- List + LazyForEach —— 内层列表组件,负责按需加载商品数据
- Tabs —— 顶部页签切换,“精选”、“手机”、"服饰"等分类一目了然
环境准备:先确保工具链就绪
开始之前,请确认你的开发环境满足以下要求:
| 项目 | 最低版本要求 |
|---|---|
| DevEco Studio | 6.0.2 Release 及以上 |
| HarmonyOS SDK | 6.0.2 Release SDK 及以上 |
| 设备系统 | HarmonyOS 5.0.5 Release 及以上 |
| 支持设备 | 华为手机、模拟器 |
项目结构:简洁清晰的分层
这个项目的代码结构很规整,采用了经典的 MVVM 分层思想:
entry/src/main/ets/
├── common/
│ └── CommonConstants.ets # 常量集合(尺寸、字号、偏移量等)
├── entryability/
│ └── EntryAbility.ets # 应用入口,承载生命周期 + 沉浸式适配
├── pages/
│ └── ListIndex.ets # 页面入口(Navigation + TabBar)
├── view/
│ ├── GoodsListComponent.ets # 商品列表组件(List + LazyForEach)
│ ├── PutDownRefreshLayout.ets # 下拉刷新组件
│ └── TabBarsComponent.ets # Tabs 页签组件(核心:Scroll 嵌套 List)
└── viewmodel/
├── InitialData.ets # 初始化数据 + 商品实体类定义
└── ListDataSource.ets # List 数据源(懒加载的核心)
简单来说:
- viewmodel 负责数据和业务逻辑
- view 负责 UI 组件
- pages 是页面入口
- common 放全局常量
这样分层的最大好处是:改界面不影响数据,改数据不影响界面,后期维护起来很省心。
第一步:常量定义 —— 把"魔法数字"变成可读常量
先看 CommonConstants.ets,这里定义了项目中用到的所有尺寸、字号、间距等常量:
// common/CommonConstants.ets
// 千分比布局
export const GOODS_LIST_HEIGHT: string = '20%';
export const GOODS_IMAGE_WIDTH: string = '40%';
export const GOODS_FONT_WIDTH: string = '60%';
export const EVALUATE_WIDTH: string = '80%';
export const GOODS_LIST_WIDTH: string = '94%';
export const LAYOUT_WIDTH_OR_HEIGHT: string = '100%';
// 字号
export const GOODS_LIST_PADDING: number = 8;
export const GOODS_EVALUATE_FONT_SIZE: number = 12;
export const NORMAL_FONT_SIZE: number = 16;
export const BIGGER_FONT_SIZE: number = 20;
export const MAX_FONT_SIZE: number = 32;
// 间距
export const REFRESH_ICON_MARGIN_RIGHT: number = 20;
export const MARGIN_RIGHT: number = 32;
// 图标尺寸
export const ICON_WIDTH: number = 40;
export const ICON_HEIGHT: number = 40;
// 列表项间距
export const LIST_ITEM_SPACE: number = 16;
// 导航标题
export const STORE: ResourceStr = $r('app.string.shopping_mall');
// 下拉刷新偏移量阈值
export const MAX_OFFSET_Y: number = 100;
// 刷新动画时长
export const REFRESH_TIME: number = 1500;
// 数据倍数和上限
export const MAGNIFICATION: number = 2;
export const MAX_DATA_LENGTH: number = 12;
// 最大行数
export const MAX_LINES_TEXT: number = 1;
看到没?MAX_OFFSET_Y 是下拉刷新的触发阈值(100vp),REFRESH_TIME 是刷新提示的显示时长(1.5秒),MAX_DATA_LENGTH 是懒加载的数据上限(12条)。把这些值抽成常量,改的时候一处改全,不用满世界找"魔法数字"。
第二步:数据模型 —— 商品长什么样?
在 InitialData.ets 里,我们先定义商品的实体类和初始化数据:
// viewmodel/InitialData.ets
// Tab页签数据(手机、服饰、穿搭、家居)
export const initTabBarData = [
$r('app.string.mobile_phone'),
$r('app.string.clothes'),
$r('app.string.wear'),
$r('app.string.home_furnishing')
]
// 商品实体类
export class GoodsListItemType {
goodsImg: Resource; // 商品图片
goodsName: Resource; // 商品名称
advertisingLanguage: Resource; // 广告语
evaluate: Resource; // 评价信息
price: Resource; // 价格
constructor(goodsImg: Resource, goodsName: Resource, price: Resource) {
this.goodsImg = goodsImg;
this.goodsName = goodsName;
this.advertisingLanguage = $r('app.string.advertising_language');
this.evaluate = $r('app.string.evaluate');
this.price = price;
}
}
// 初始化4条商品数据
export const goodsInitialList: GoodsListItemType[] = [
new GoodsListItemType($r('app.media.goodsImg'), $r('app.string.goodsName'), $r('app.string.price_199')),
new GoodsListItemType($r('app.media.goodsImg_2'), $r('app.string.another_goodsName'), $r('app.string.price_199')),
new GoodsListItemType($r('app.media.goodsImg_3'), $r('app.string.goodsName'), $r('app.string.price_199')),
new GoodsListItemType($r('app.media.goodsImg_4'), $r('app.string.another_goodsName'), $r('app.string.price_199'))
]
这里的设计很巧妙:GoodsListItemType 的构造函数只需要三个参数(图片、名称、价格),广告语和评价信息自动填充默认值。因为一个列表里大部分商品的评价展示方式是一样的,没必要每次都手动传。
第三步:数据源 —— 懒加载的"发动机"
ListDataSource.ets 是整个懒加载功能的核心。它继承自 BasicDataSource(实现了 IDataSource 接口),负责管理数据增删和数据变更通知:
// viewmodel/ListDataSource.ets
import { goodsInitialList, GoodsListItemType } from './InitialData';
import { MAGNIFICATION, MAX_DATA_LENGTH } from '../common/CommonConstants';
/**
* 创建初始数据范围:初始加载 MAGNIFICATION(2) 倍的基础数据
*/
const createListRange = (): GoodsListItemType[] => {
let result = new Array<GoodsListItemType>();
for (let i = 0; i < MAGNIFICATION; i++) {
result = result.concat(goodsInitialList);
}
return result;
}
/**
* 基础数据源类:实现 IDataSource 接口
*/
class BasicDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
public totalCount(): number {
return 0;
}
public getData(index: number): GoodsListItemType | undefined {
return undefined;
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const position = this.listeners.indexOf(listener);
if (position >= 0) {
this.listeners.splice(position, 1);
}
}
notifyDataReload(): void {
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
})
}
notifyDataAdd(index: number): void {
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataAdd(index);
})
}
notifyDataChange(index: number): void {
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataChange(index);
})
}
notifyDataDelete(index: number): void {
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataDelete(index);
})
}
notifyDataMove(from: number, to: number): void {
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataMove(from, to);
})
}
}
/**
* 商品列表数据源:继承 BasicDataSource
*/
export class ListDataSource extends BasicDataSource {
private listData = createListRange(); // 初始加载 2 x 4 = 8 条数据
public totalCount(): number {
return this.listData.length;
}
public getData(index: number): GoodsListItemType {
return this.listData[index];
}
/**
* 懒加载:向下滑动时追加数据
* 最多加载 MAX_DATA_LENGTH(12) 条
*/
public pushData(): void {
if (this.listData.length < MAX_DATA_LENGTH) {
this.listData = this.listData.concat(goodsInitialList);
this.notifyDataAdd(this.listData.length - 1);
}
}
}
关键点解析:
createListRange()—— 初始创建 2 倍基础数据(2 x 4 = 8条),所以页面一打开就能看到 8 个商品BasicDataSource—— 实现了IDataSource接口的数据监听机制,数据变化时自动通知 UI 更新pushData()—— 懒加载的核心方法:每次追加 4 条数据,上限 12 条。注意调用notifyDataAdd()后,LazyForEach 会自动渲染新增的数据项
第四步:页面入口 —— Navigation + Tabs 搭框架
ListIndex.ets 是整个页面的入口,结构很简洁:外层 Navigation 做导航栏,内部放 TabBar 组件:
// pages/ListIndex.ets
import TabBar from '../view/TabBarsComponent';
import { LAYOUT_WIDTH_OR_HEIGHT, STORE } from '../common/CommonConstants';
@Entry
@Component
struct ListIndex {
build() {
Row() {
Navigation() {
Column() {
TabBar()
}
.width(LAYOUT_WIDTH_OR_HEIGHT)
.justifyContent(FlexAlign.Center)
}
.size({ width: LAYOUT_WIDTH_OR_HEIGHT, height: LAYOUT_WIDTH_OR_HEIGHT })
.title(STORE)
.titleMode(NavigationTitleMode.Mini)
}
.height(LAYOUT_WIDTH_OR_HEIGHT)
.backgroundColor($r('app.color.primaryBgColor'))
.padding({
top: AppStorage.get<number>('statusBarHeight')
})
}
}
这里的 titleMode(NavigationTitleMode.Mini) 让导航栏标题以迷你模式显示,节省空间。padding 里的 statusBarHeight 是在 EntryAbility 里计算好的状态栏高度,确保沉浸式布局时内容不被状态栏遮挡。
第五步:Tab 页签 + 下拉刷新 —— Scroll 嵌套 List 的精髓
TabBarsComponent.ets 是整个 Demo 的核心组件,它把 Tabs、Scroll、List、下拉刷新 全串起来了:
// view/TabBarsComponent.ets
import { initTabBarData } from '../viewmodel/InitialData';
import {
LAYOUT_WIDTH_OR_HEIGHT,
NORMAL_FONT_SIZE,
BIGGER_FONT_SIZE,
MAX_FONT_SIZE,
MAX_OFFSET_Y,
REFRESH_TIME,
GOODS_EVALUATE_FONT_SIZE,
MAX_LINES_TEXT
} from '../common/CommonConstants';
import GoodsList from './GoodsListComponent';
import PutDownRefresh from './PutDownRefreshLayout';
@Component
export default struct TabBar {
private currentOffsetY: number = 0;
private timer: number = 0;
@State tabsIndex: number = 0;
@State refreshStatus: boolean = false;
@State refreshText: Resource = $r('app.string.refresh_text');
@State clickOrder: number = 0;
@Builder
firstTabBar() {
Column() {
Text($r('app.string.selected'))
.fontSize(this.tabsIndex === 0 ? BIGGER_FONT_SIZE : NORMAL_FONT_SIZE)
.fontColor(this.tabsIndex === 0 ? Color.Black : $r('app.color.gray'))
.maxLines(MAX_LINES_TEXT)
.minFontSize(this.tabsIndex === 0 ? NORMAL_FONT_SIZE : GOODS_EVALUATE_FONT_SIZE)
.maxFontSize(this.tabsIndex === 0 ? BIGGER_FONT_SIZE : NORMAL_FONT_SIZE)
}
.width(LAYOUT_WIDTH_OR_HEIGHT)
.height(LAYOUT_WIDTH_OR_HEIGHT)
.justifyContent(FlexAlign.Center)
}
@Builder
otherTabBar(content: Resource, index: number) {
Column() {
Text(content)
.fontSize(this.tabsIndex === index + 1 ? BIGGER_FONT_SIZE : NORMAL_FONT_SIZE)
.fontColor(this.tabsIndex === index + 1 ? Color.Black : $r('app.color.gray'))
.maxLines(MAX_LINES_TEXT)
.minFontSize(this.tabsIndex === index + 1 ? NORMAL_FONT_SIZE : GOODS_EVALUATE_FONT_SIZE)
.maxFontSize(this.tabsIndex === index + 1 ? BIGGER_FONT_SIZE : NORMAL_FONT_SIZE)
}
.width(LAYOUT_WIDTH_OR_HEIGHT)
.height(LAYOUT_WIDTH_OR_HEIGHT)
.justifyContent(FlexAlign.Center)
}
putDownRefresh(event?: TouchEvent): void {
if (event === undefined) {
return;
}
switch (event.type) {
// 记录手指按下的Y坐标
case TouchType.Down:
this.currentOffsetY = event.touches[0].y;
break;
case TouchType.Move:
if (this.clickOrder < 5) {
// 根据下拉偏移量判断是否触发刷新
this.refreshStatus = event.touches[0].y - this.currentOffsetY > MAX_OFFSET_Y;
}
break;
case TouchType.Cancel:
break;
case TouchType.Up:
// 模拟刷新效果(实际项目中应在这里发起网络请求)
this.timer = setTimeout(() => {
this.refreshStatus = false;
}, REFRESH_TIME);
break;
default:
break;
}
}
aboutToDisappear() {
clearTimeout(this.timer);
}
build() {
Tabs() {
TabContent() {
Scroll() {
Column() {
// 条件渲染:下拉时显示刷新组件
if (this.refreshStatus) {
PutDownRefresh({ refreshText: $refreshText })
}
// 商品列表
GoodsList({ clickOrder: this.clickOrder })
// 到底提示
Text($r('app.string.to_bottom')).fontSize(NORMAL_FONT_SIZE).fontColor($r('app.color.gray'))
}
.width(LAYOUT_WIDTH_OR_HEIGHT)
.padding({ bottom: 12 })
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.width(LAYOUT_WIDTH_OR_HEIGHT)
.height(LAYOUT_WIDTH_OR_HEIGHT)
.onTouch((event?: TouchEvent) => {
this.putDownRefresh(event);
})
}
.tabBar(this.firstTabBar)
// 其他Tab页(手机、服饰、穿搭、家居)
ForEach(initTabBarData, (item: Resource, index?: number) => {
TabContent() {
Column() {
Text(item).fontSize(MAX_FONT_SIZE)
}
.justifyContent(FlexAlign.Center)
.width(LAYOUT_WIDTH_OR_HEIGHT)
.height(LAYOUT_WIDTH_OR_HEIGHT)
}
.tabBar(this.otherTabBar(item, index !== undefined ? index : 0))
})
}
.onChange((index: number) => {
this.tabsIndex = index;
})
.vertical(false)
}
}
这段代码有几个值得细说的设计:
下拉刷新的实现原理
putDownRefresh() 方法通过 onTouch 事件监听手势:
- TouchType.Down —— 记录手指按下时的 Y 坐标
- TouchType.Move —— 实时计算下拉偏移量,超过
MAX_OFFSET_Y(100vp)时显示刷新组件 - TouchType.Up —— 手指抬起后,延迟 1.5 秒隐藏刷新提示(模拟网络请求)
注意 if (this.clickOrder < 5) 这个条件——当列表前 5 项还在屏幕上时才触发下拉刷新,避免滚动到列表中间位置时误触。
Scroll + List 联动
Scroll 作为外层容器处理下拉刷新和回弹(edgeEffect(EdgeEffect.Spring)),List 作为内层组件负责商品数据的懒加载渲染。两者通过 onTouch 事件协调工作。
第六步:商品列表组件 —— LazyForEach 按需渲染
GoodsListComponent.ets 是列表的 UI 组件,使用 LazyForEach 实现数据的按需加载:
// view/GoodsListComponent.ets
import * as commonConst from '../common/CommonConstants';
import { GoodsListItemType } from '../viewmodel/InitialData';
import { ListDataSource } from '../viewmodel/ListDataSource';
@Component
export default struct GoodsList {
@Provide goodsListData: ListDataSource = new ListDataSource();
@Link clickOrder: number;
private startTouchOffsetY: number = 0;
private endTouchOffsetY: number = 0;
build() {
Row() {
List({ space: commonConst.LIST_ITEM_SPACE }) {
LazyForEach(this.goodsListData, (item: GoodsListItemType, index: number) => {
ListItem() {
Row() {
// 左侧:商品图片
Column() {
Image(item?.goodsImg)
.width(commonConst.LAYOUT_WIDTH_OR_HEIGHT)
.height(commonConst.LAYOUT_WIDTH_OR_HEIGHT)
.draggable(false)
}
.width(commonConst.GOODS_IMAGE_WIDTH)
.height(commonConst.LAYOUT_WIDTH_OR_HEIGHT)
// 右侧:商品信息
Column() {
Text(item?.goodsName)
.fontSize(commonConst.NORMAL_FONT_SIZE)
Text(item?.advertisingLanguage)
.fontColor($r('app.color.gray'))
.fontSize(commonConst.GOODS_EVALUATE_FONT_SIZE)
.margin({ right: commonConst.MARGIN_RIGHT })
Row() {
Text(item?.evaluate)
.fontSize(commonConst.GOODS_EVALUATE_FONT_SIZE)
.fontColor($r('app.color.deepGray'))
.width(commonConst.EVALUATE_WIDTH)
Text(item?.price).fontSize(commonConst.NORMAL_FONT_SIZE).fontColor($r('app.color.freshRed'))
}
.justifyContent(FlexAlign.SpaceAround)
.width(commonConst.GOODS_LIST_WIDTH)
}
.padding(commonConst.GOODS_LIST_PADDING)
.width(commonConst.GOODS_FONT_WIDTH)
.height(commonConst.LAYOUT_WIDTH_OR_HEIGHT)
.justifyContent(FlexAlign.SpaceBetween)
}
.justifyContent(FlexAlign.SpaceBetween)
.height(commonConst.GOODS_LIST_HEIGHT)
.width(commonConst.LAYOUT_WIDTH_OR_HEIGHT)
.draggable(false)
}
.onTouch((event?: TouchEvent) => {
this.clickOrder = index;
if (event === undefined) {
return;
}
switch (event.type) {
case TouchType.Down:
this.startTouchOffsetY = event.touches[0].y;
break;
case TouchType.Up:
this.startTouchOffsetY = event.touches[0].y;
break;
case TouchType.Move:
// 向下滑动时触发懒加载
if (this.startTouchOffsetY - this.endTouchOffsetY > 0) {
this.goodsListData.pushData();
}
break;
}
})
})
}
.width(commonConst.GOODS_LIST_WIDTH)
}
.justifyContent(FlexAlign.Center)
.width(commonConst.LAYOUT_WIDTH_OR_HEIGHT)
}
}
这里有几个重要的设计细节:
为什么用 LazyForEach 而不是 ForEach?
ForEach 会一次性把所有数据遍历渲染成组件。如果你的商品列表有几百上千条数据,一次性全部渲染会严重拖慢性能,甚至导致页面卡顿。
LazyForEach 则是按需渲染——只有即将进入可视区域的列表项才会被创建组件,离开可视区域的组件会被销毁回收。这就是"懒加载"的核心思想。
懒加载的触发时机
在 onTouch 的 TouchType.Move 分支中,当检测到手指向下滑动时(startTouchOffsetY - endTouchOffsetY > 0),就调用 this.goodsListData.pushData() 追加数据。配合 ListDataSource 里的 MAX_DATA_LENGTH 上限控制,避免无限加载。
商品项的布局
每个商品项是一个横向的 Row,左侧放图片(占 40%),右侧放商品信息(占 60%),信息包括名称、广告语和价格/评价行。布局简洁清晰,类似淘宝、京东的商品列表样式。
第七步:下拉刷新组件 —— 一个小组件搞定
PutDownRefreshLayout.ets 是最简单的组件,就是一个图标加文字:
// view/PutDownRefreshLayout.ets
import * as commonConst from '../common/CommonConstants';
@Component
export default struct PutDownRefresh {
@Link refreshText: Resource;
build() {
Row() {
Image($r('app.media.refreshing'))
.width(commonConst.ICON_WIDTH)
.height(commonConst.ICON_HEIGHT)
Text(this.refreshText).fontSize(commonConst.NORMAL_FONT_SIZE)
}
.justifyContent(FlexAlign.Center)
.width(commonConst.GOODS_LIST_WIDTH)
.height(commonConst.GOODS_LIST_HEIGHT)
}
}
虽然简单,但别忘了 @Link refreshText —— 它和父组件 TabBarsComponent 里的 refreshText 双向绑定,这样刷新文案的更新可以实时反映在 UI 上。
第八步:应用入口 —— 沉浸式布局适配
最后看一下 EntryAbility.ets,除了标准的生命周期管理,它还做了沉浸式布局适配:
// entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
hilog.info(0x0000, 'testTag', '%{public}s', 'want param:' + JSON.stringify(want) ?? '');
hilog.info(0x0000, 'testTag', '%{public}s', 'launchParam:' + JSON.stringify(launchParam) ?? '');
}
onDestroy(): void | Promise<void> {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/ListIndex', (err, data) => {
if (err.code) {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.ERROR);
hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
// 沉浸式适配
this.immersionFuc(windowStage);
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? '');
});
}
onWindowStageDestroy(): void {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
onForeground(): void {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
hilog.isLoggable(0x0000, 'testTag', hilog.LogLevel.INFO);
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
}
/**
* 沉浸式布局适配
*/
immersionFuc(windowStage: window.WindowStage): void {
try {
let windowClass: window.Window = windowStage.getMainWindowSync();
windowClass.setWindowLayoutFullScreen(true).catch((err: BusinessError) => {
hilog.error(0x0000, 'testTag', '%{public}s',
`SetWindowLayoutFullScreen failed. Cause code: ${err.code}, message: ${err.message}`);
});
let navigationBarArea: window.AvoidArea =
windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
let area: window.AvoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
AppStorage.setOrCreate<number>('naviIndicatorHeight',
windowClass.getUIContext().px2vp(navigationBarArea.bottomRect.height));
AppStorage.setOrCreate<number>('statusBarHeight', windowClass.getUIContext().px2vp(area.topRect.height));
AppStorage.setOrCreate<window.Window>('windowClass', windowClass);
} catch (err) {
hilog.error(0x0000, 'testTag', '%{public}s',
`immersionFuc failed, error code=${err.code}, message=${err.message}`);
}
}
}
immersionFuc() 做了三件事:
- 设置全屏布局模式(
setWindowLayoutFullScreen(true)) - 获取状态栏和导航栏的避让区域高度
- 将高度值存入
AppStorage,供页面布局使用
这就是为什么 ListIndex.ets 里能通过 AppStorage.get<number>('statusBarHeight') 拿到状态栏高度来做顶部 padding。
资源文件补充:字符串和颜色
为了完整性,这里把用到的资源文件也列一下:
字符串资源 (resources/zh_CN/element/string.json):
{
"string": [
{ "name": "selected", "value": "精选" },
{ "name": "mobile_phone", "value": "手机" },
{ "name": "clothes", "value": "服饰" },
{ "name": "wear", "value": "穿搭" },
{ "name": "home_furnishing", "value": "家居" },
{ "name": "goodsName", "value": "【新品上市】畅乐冰晶绿低脂新品" },
{ "name": "another_goodsName", "value": "【新品上市】奶茶自然清新亲近自然" },
{ "name": "advertising_language", "value": "重磅推荐,MD新品试用中!" },
{ "name": "evaluate", "value": "6662人评价 95%好评" },
{ "name": "price_199", "value": "¥199" },
{ "name": "to_bottom", "value": "-- 已经到底了 --" },
{ "name": "refresh_text", "value": "正在刷新" },
{ "name": "shopping_mall", "value": "商城" }
]
}
颜色资源 (resources/base/element/color.json):
{
"color": [
{ "name": "white", "value": "#FFFFFF" },
{ "name": "primaryBgColor", "value": "#F1F3F5" },
{ "name": "gray", "value": "#989A9C" },
{ "name": "deepGray", "value": "#182431" },
{ "name": "freshRed", "value": "#E92F4F" }
]
}
总结:三个核心知识点的回顾
到这里,我们已经完成了整个商品列表页面的开发。来总结一下关键知识点:
-
Scroll + List 联动布局 —— Scroll 做外层滚动容器处理下拉手势和回弹效果,List 做内层列表按需渲染商品项,两者各司其职又配合默契。
-
LazyForEach 懒加载 —— 通过自定义
IDataSource数据源,配合LazyForEach组件,实现数据的按需加载和动态追加。当向下滑动时自动触发pushData()追加新数据,达到上限后自动停止。 -
onTouch 手势实现下拉刷新 —— 通过监听 Touch 事件的三种状态(Down、Move、Up),计算下拉偏移量判断是否触发刷新。
EdgeEffect.Spring则为到底回弹提供了物理弹簧效果。
这个 Demo 虽然不复杂,但覆盖了实际项目中商品列表页面的核心需求。你可以在它的基础上继续扩展——比如接入真实的网络请求做数据拉取、添加骨架屏加载效果、支持上拉加载更多等等。
更多推荐



所有评论(0)