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
@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'))
}
.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'))
}
.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
@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'))
}
.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'))
}
.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 = 0
private endTouchOffsetY: number = 0
build() {
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].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)
}
}
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: 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
}
}
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 = 0
private currentOffsetY: number = 0
@State tabsIndex: number = 0
@State refreshStatus: boolean = false
@State refreshText: Resource = $r('app.string.refresh_text')
@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'))
}
.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'))
}
.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:
// 根据下拉偏移量确定是否刷新。
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()
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: 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)
}
}
9. 代码与视频教程
完整案例代码与视频教程请参见:
代码:Code-06-01.zip。
视频:《实现商品列表》。