实现商品列表

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。

视频:《实现商品列表》。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值