实现商品列表
HarmonyOS ArkTS提供了丰富的接口和组件,开发者可以根据实际场景和开发需求,选用不同的组件和接口。在本案例中,我们使用Scroll组件、List组件以及LazyForEach组件实现一个商品列表的页面,并且拥有下拉刷新、懒加载和到底提示的效果。
1. 案例效果截图
2. 案例运用到的知识点
2.1. 核心知识点
- Scroll:可滚动的容器组件,当子组件的布局尺寸超过父组件的视口时,内容可以滚动。
- List:列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。
- Tabs:一种可以通过页签进行内容视图切换的容器组件,每个页签对应一个内容视图。
- LazyForEach:开发框架提供数据懒加载(LazyForEach组件)从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。
2.2. 其他知识点
- ArkTS 语言基础
- V2版状态管理:@ComponentV2/@Provider/@Consumer
- 自定义组件和组件生命周期
- 内置组件:Column/Button
- 日志管理类的编写
- 常量与资源分类的访问
- MVVM模式
3. 代码结构
├──entry/src/main/ets // 代码区
│ ├──common
│ │ └──CommonConstants.ets // 常量集合文件
│ ├──entryability
│ │ └──EntryAbility.ets // 应用入口,承载应用的生命周期
│ ├──pages
│ │ └──ListIndex.ets // 页面入口
│ ├──view
│ │ ├──GoodsListComponent.ets // 商品列表组件
│ │ ├──PutDownRefreshLayout.ets // 下拉刷新组件
│ │ └──TabBarsComponent.ets // Tabs组件
│ └──viewmodel
│ ├──InitialData.ets // 初始化数据
│ └──ListDataSource.ets // List使用的相关数据加载
└──entry/src/main/resources // 资源文件目录
4. 公共文件与资源
本案例涉及到的常量类代码如下:
// entry/src/main/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 GOODS_LIST_WIDTH: string = '94%'
export const LAYOUT_WIDTH_OR_HEIGHT: string = '100%'// font-size
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// margin
export const REFRESH_ICON_MARGIN_RIGHT: number = 20
export const MARGIN_RIGHT: number = 32// width or height
export const ICON_WIDTH: number = 40
export const ICON_HEIGHT: number = 40// list space
export const LIST_ITEM_SPACE: number = 16// navigation title
export const STORE: string = '商城'// offset range
export const MAX_OFFSET_Y: number = 100// refresh time
export const REFRESH_TIME: number = 1500// Magnification
export const MAGNIFICATION: number = 2
export const MAX_DATA_LENGTH: number = 12
本案例涉及到的资源文件如下:
4.1. string.json
// entry/src/main/resources/base/element/string.json
{"string": [{"name": "entry_desc","value": "description"},{"name": "EntryAbility_desc","value": "description"},{"name": "EntryAbility_label","value": "List组件"},{"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": "price_265","value": "¥265"},{"name": "price_810","value": "¥810"},{"name": "price_999","value": "¥999"},{"name": "to_bottom","value": "-- 已经到底了 --"},{"name": "refresh_text","value": "正在刷新"}]
}
4.2. color.json
// entry/src/main/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"}]
}
其他资源请到源码中获取。
5. 首页
// entry/src/main/ets/pages/Index.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'))}
}
6. 实现Tabs
// entry/src/main/ets/view/TabBarsComponent.ets
import { initTabBarData } from '../viewmodel/InitialData'
import {LAYOUT_WIDTH_OR_HEIGHT,NORMAL_FONT_SIZE,BIGGER_FONT_SIZE,MAX_FONT_SIZE
} from '../common/CommonConstants'@Component
export default struct TabBar {@State tabsIndex: number = 0@BuilderfirstTabBar() {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'))}.width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT).justifyContent(FlexAlign.Center)}@BuilderotherTabBar(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'))}.width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT).justifyContent(FlexAlign.Center)}build() {Tabs() {TabContent() {Scroll() {Column() {Text('商品列表')}.width(LAYOUT_WIDTH_OR_HEIGHT)}.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring).width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT)}.tabBar(this.firstTabBar)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)}
}
7. 商品列表和懒加载
7.1. 添加入口
// entry/src/main/ets/view/TabBarsComponent.ets
import { initTabBarData } from '../viewmodel/InitialData'
import {LAYOUT_WIDTH_OR_HEIGHT,NORMAL_FONT_SIZE,BIGGER_FONT_SIZE,MAX_FONT_SIZE
} from '../common/CommonConstants'
import GoodsList from './GoodsListComponent'@Component
export default struct TabBar {@State tabsIndex: number = 0@BuilderfirstTabBar() {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'))}.width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT).justifyContent(FlexAlign.Center)}@BuilderotherTabBar(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'))}.width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT).justifyContent(FlexAlign.Center)}build() {Tabs() {TabContent() {Scroll() {Column() {GoodsList()}.width(LAYOUT_WIDTH_OR_HEIGHT)}.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring).width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT)}.tabBar(this.firstTabBar)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)}
}
7.2. 商品列表懒加载
// entry/src/main/ets/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()private startTouchOffsetY: number = 0private endTouchOffsetY: number = 0build() {Row() {List({ space: commonConst.LIST_ITEM_SPACE }) {LazyForEach(this.goodsListData, (item: GoodsListItemType) => {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).margin({ bottom: commonConst.BIGGER_FONT_SIZE })Text(item?.advertisingLanguage).fontColor($r('app.color.gray')).fontSize(commonConst.GOODS_EVALUATE_FONT_SIZE).margin({ right: commonConst.MARGIN_RIGHT, bottom: commonConst.BIGGER_FONT_SIZE })Row() {Text(item?.evaluate).fontSize(commonConst.GOODS_EVALUATE_FONT_SIZE).fontColor($r('app.color.deepGray'))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).height(commonConst.GOODS_LIST_HEIGHT).width(commonConst.LAYOUT_WIDTH_OR_HEIGHT)}.onTouch((event?: TouchEvent) => {if (event === undefined) {return}switch (event.type) {case TouchType.Down:this.startTouchOffsetY = event.touches[0].ybreakcase TouchType.Up:this.startTouchOffsetY = event.touches[0].ybreakcase 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)}
}
7.3. 定义数据类型
// entry/src/main/ets/viewmodel/ListDataSource.ets
import { goodsInitialList, GoodsListItemType } from './InitialData'
import { MAGNIFICATION, MAX_DATA_LENGTH } from '../common/CommonConstants'/*** 创建一个范围列表。*/
const createListRange = (): GoodsListItemType[] => {let result = new Array<GoodsListItemType>()for (let i = 0; i < MAGNIFICATION; i++) {result = result.concat(goodsInitialList)}return result
}/*** LazyLoad 类实现*/
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)})}
}export class ListDataSource extends BasicDataSource {private listData = createListRange()public totalCount(): number {return this.listData.length}public getData(index: number): GoodsListItemType {return this.listData[index]}public pushData(): void {if (this.listData.length < MAX_DATA_LENGTH) {this.listData = this.listData.concat(goodsInitialList)this.notifyDataAdd(this.listData.length - 1)}}
}
7.4. 数据初始化
// entry/src/main/ets/viewmodel/InitialData.ets
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: ResourcegoodsName: ResourceadvertisingLanguage: Resourceevaluate: Resourceprice: Resourceconstructor(goodsImg: Resource, goodsName: Resource, price: Resource) {this.goodsImg = goodsImgthis.goodsName = goodsNamethis.advertisingLanguage = $r('app.string.advertising_language')this.evaluate = $r('app.string.evaluate')this.price = price}
}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'))
]
8. 下拉刷新与到底提示
8.1. 下拉刷新
// entry/src/main/ets/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
} from '../common/CommonConstants'
import GoodsList from './GoodsListComponent'
import PutDownRefresh from './PutDownRefreshLayout'@Component
export default struct TabBar {private timer: number = 0private currentOffsetY: number = 0@State tabsIndex: number = 0@State refreshStatus: boolean = false@State refreshText: Resource = $r('app.string.refresh_text')@BuilderfirstTabBar() {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'))}.width(LAYOUT_WIDTH_OR_HEIGHT).height(LAYOUT_WIDTH_OR_HEIGHT).justifyContent(FlexAlign.Center)}@BuilderotherTabBar(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'))}.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].ybreakcase TouchType.Move:// 根据下拉偏移量确定是否刷新。this.refreshStatus = event.touches[0].y - this.currentOffsetY > MAX_OFFSET_Ybreakcase TouchType.Cancel:breakcase TouchType.Up:// 仅模拟效果,不进行数据请求。this.timer = setTimeout(() => {this.refreshStatus = false}, REFRESH_TIME)breakdefault:break}}aboutToDisappear() {clearTimeout(this.timer)}build() {Tabs() {TabContent() {Scroll() {Column() {if (this.refreshStatus) {PutDownRefresh({ refreshText: $refreshText })}GoodsList()Text($r('app.string.to_bottom')).fontSize(NORMAL_FONT_SIZE).fontColor($r('app.color.gray'))}.width(LAYOUT_WIDTH_OR_HEIGHT)}.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)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)}
}
8.2. 到底显示
// entry/src/main/ets/view/PutDownRefreshLayout.ets
import * as commonConst from '../common/CommonConstants'@Component
export default struct PutDownRefresh {@Link refreshText: Resourcebuild() {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)}
}
9. 代码与视频教程
完整案例代码与视频教程请参见:
代码:Code-06-01.zip。
视频:《实现商品列表》。