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);
    }
  }
}

关键点解析:

  1. createListRange() —— 初始创建 2 倍基础数据(2 x 4 = 8条),所以页面一打开就能看到 8 个商品
  2. BasicDataSource —— 实现了 IDataSource 接口的数据监听机制,数据变化时自动通知 UI 更新
  3. 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 事件监听手势:

  1. TouchType.Down —— 记录手指按下时的 Y 坐标
  2. TouchType.Move —— 实时计算下拉偏移量,超过 MAX_OFFSET_Y(100vp)时显示刷新组件
  3. 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 则是按需渲染——只有即将进入可视区域的列表项才会被创建组件,离开可视区域的组件会被销毁回收。这就是"懒加载"的核心思想。

懒加载的触发时机

onTouchTouchType.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() 做了三件事:

  1. 设置全屏布局模式(setWindowLayoutFullScreen(true)
  2. 获取状态栏和导航栏的避让区域高度
  3. 将高度值存入 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" }
  ]
}

总结:三个核心知识点的回顾

到这里,我们已经完成了整个商品列表页面的开发。来总结一下关键知识点:

  1. Scroll + List 联动布局 —— Scroll 做外层滚动容器处理下拉手势和回弹效果,List 做内层列表按需渲染商品项,两者各司其职又配合默契。

  2. LazyForEach 懒加载 —— 通过自定义 IDataSource 数据源,配合 LazyForEach 组件,实现数据的按需加载和动态追加。当向下滑动时自动触发 pushData() 追加新数据,达到上限后自动停止。

  3. onTouch 手势实现下拉刷新 —— 通过监听 Touch 事件的三种状态(Down、Move、Up),计算下拉偏移量判断是否触发刷新。EdgeEffect.Spring 则为到底回弹提供了物理弹簧效果。

这个 Demo 虽然不复杂,但覆盖了实际项目中商品列表页面的核心需求。你可以在它的基础上继续扩展——比如接入真实的网络请求做数据拉取、添加骨架屏加载效果、支持上拉加载更多等等。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐