HarmonyOS APP开发---"好物清单"购物清单App,需要用到这个库

HarmonyOS APP开发---"好物清单"购物清单App,需要用到这个库

比价 App 的核心体验就是"下拉一下看有没有降价"。再加上推荐列表上拉加载、点商品弹出价格历史(BindSheet 半模态窗口里也要能刷新)、分类 Tab 各自独立刷新------四种列表交互,@ohos/pulltorefresh 一个组件全包。
📦 仓库地址 | ohpm install @ohos/pulltorefresh


场景

"好物清单"的列表交互特别多:

  • 我的收藏:下拉刷新查最新价格,降价了标红提示
  • 推荐商品:上拉加载更多
  • 历史价格弹窗:点商品用 BindSheet 半模态弹窗展示价格走势,弹窗里也要能刷新
  • 分类 Tab:数码/服饰/家居,每个 Tab 独立刷新
  • 长列表:收藏几百件商品,要用 LazyForEach

自己写手势监听 + 动画 + 状态机,每个列表重复一遍,还得处理和系统 edgeEffect 的冲突。


为什么是 pulltorefresh

需求 自己手写 @ohos/pulltorefresh
下拉刷新 手势 + 动画 + 状态机 ✅ onRefresh 一个回调
上拉加载 滚动监听 + 触底判断 ✅ onLoadMore 一个回调
五大容器 每个容器各写一套 ✅ 通用组件
自定义动画 手写 ✅ customRefresh / customLoad
LazyForEach 需自行适配 ✅ 完美兼容
BindSheet 弹窗 更复杂 ✅ 同样支持

关键实现

收藏列表:刷新比价 + LazyForEach

typescript 复制代码
import { PullToRefresh } from '@ohos/pulltorefresh'

interface Product {
  id: string; name: string; image: string; platform: string
  currentPrice: number; lastPrice: number
}

@Entry
@Component
struct WishlistPage {
  private scroller: Scroller = new Scroller()
  @State products: Product[] = []
  @State priceChangedCount: number = 0

  build() {
    Column() {
      if (this.priceChangedCount > 0) {
        Text(`🎉 ${this.priceChangedCount} 件商品降价了!`)
          .fontSize(14).fontColor('#FF6B6B').padding(10)
          .width('100%').textAlign(TextAlign.Center).backgroundColor('#FFF5F5')
      }

      PullToRefresh({
        data: $products,
        scroller: this.scroller,
        customList: () => { this.productListView() },

        // ✅ 下拉刷新:查最新价格
        onRefresh: () => new Promise<string>((resolve) => {
          refreshPrices(this.products.map((p) => p.id))
            .then((latest: Record<string, number>) => {
              let changed = 0
              this.products = this.products.map((p) => {
                const np = latest[p.id]
                if (np !== undefined && np !== p.currentPrice) {
                  changed++
                  return { ...p, lastPrice: p.currentPrice, currentPrice: np }
                }
                return p
              })
              this.priceChangedCount = changed
              resolve(changed > 0 ? `${changed} 件商品有价格变动` : '已是最新价格')
            })
            .catch(() => resolve(''))     // ✅ 失败也要 resolve
        }),

        // ✅ 上拉加载:更多推荐
        onLoadMore: () => new Promise<string>((resolve) => {
          loadMoreRecommendations(this.products.length)
            .then((more: Product[]) => { this.products = this.products.concat(more); resolve('') })
            .catch(() => resolve(''))
        }),
      })
    }.width('100%').height('100%')
  }

  @Builder
  productListView() {
    List({ scroller: this.scroller }) {
      LazyForEach(this.dataSource, (item: Product) => {     // ✅ 几百件也不卡
        ListItem() {
          Row({ space: 12 }) {
            Image(item.image).width(80).height(80).borderRadius(8)
            Column({ space: 4 }) {
              Text(item.name).fontSize(15).maxLines(2)
              Row({ space: 6 }) {
                Text(`¥${item.currentPrice.toFixed(2)}`)
                  .fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FF6B6B')
                if (item.currentPrice < item.lastPrice) {
                  Text(`↓ 原价 ¥${item.lastPrice.toFixed(2)}`)
                    .fontSize(12).fontColor('#999')
                    .decoration({ type: TextDecorationType.LineThrough })
                }
              }
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          }.padding(12)
        }
        .onClick(() => this.showPriceHistory(item))
      }, (item: Product) => item.id)
    }
    .edgeEffect(EdgeEffect.None)    // ⚠️ 必须设为 None
  }
}

BindSheet 弹窗内刷新

弹窗里用 PullToRefresh 最容易漏配回调------漏了就会提示"刷新失败"或卡在刷新态:

typescript 复制代码
@State showPriceSheet: boolean = false
@State currentProduct: Product | null = null
private sheetScroller: Scroller = new Scroller()
@State priceHistory: Array<{ date: string; price: number }> = []

.bindSheet($$this.showPriceSheet, this.PriceHistorySheet(), {
  height: SheetSize.MEDIUM,
  dragBar: true,
})

@Builder
PriceHistorySheet() {
  Column() {
    Text(`${this.currentProduct?.name ?? ''} 价格历史`)
      .fontSize(16).fontWeight(FontWeight.Bold).padding(12)

    PullToRefresh({
      data: $this.priceHistory,
      scroller: this.sheetScroller,
      customList: () => { this.historyListView() },

      // ✅ 必须提供且返回 Promise
      onRefresh: () => new Promise<string>((resolve) => {
        if (!this.currentProduct) { resolve(''); return }    // 边界情况也要 resolve
        this.loadPriceHistory(this.currentProduct.id)
          .then(() => resolve('已更新'))
          .catch(() => resolve(''))
      }),

      // ✅ 不需要加载更多,但也要给空实现,不能省略
      onLoadMore: () => Promise.resolve(''),
    })
  }.width('100%').height('100%')
}

@Builder
historyListView() {
  List({ scroller: this.sheetScroller }) {
    ForEach(this.priceHistory, (item) => {
      ListItem() {
        Row() {
          Text(item.date).fontSize(14).fontColor('#666')
          Blank()
          Text(`¥${item.price.toFixed(2)}`).fontSize(14).fontColor('#FF6B6B')
        }.width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
      }
    }, (item) => item.date)
  }
  .edgeEffect(EdgeEffect.None)   // ⚠️ 弹窗内同样必须设
}

分类 Tab:每个 Tab 独立刷新

typescript 复制代码
Tabs({ controller: this.tabsController }) {
  ForEach(this.categories, (category: string) => {
    TabContent() {
      CategoryProductList({ category: category })   // ✅ 独立组件实例
    }.tabBar(category)
  }, (c: string) => c)
}

@Component
struct CategoryProductList {
  @Prop category: string
  private scroller: Scroller = new Scroller()   // ✅ 独立 scroller
  @State items: Product[] = []                   // ✅ 独立数据

  build() {
    PullToRefresh({
      data: $items,
      scroller: this.scroller,
      customList: () => { this.listView() },
      onRefresh: () => new Promise<string>((resolve) => {
        fetchByCategory(this.category).then((d) => { this.items = d; resolve('刷新成功') }).catch(() => resolve(''))
      }),
      onLoadMore: () => new Promise<string>((resolve) => {
        fetchMoreByCategory(this.category, this.items.length)
          .then((m) => { this.items = this.items.concat(m); resolve('') }).catch(() => resolve(''))
      }),
    })
  }
}

每个 Tab 用独立组件实例(独立 scroller + data + 回调),刷新互不干扰。

自定义刷新动画(品牌化)

typescript 复制代码
PullToRefresh({
  // ...
  customRefresh: () => { this.ShoppingCartRefreshView() },
  customLoad: () => { this.LoadingMoreView() },
})

@Builder
ShoppingCartRefreshView() {
  Row({ space: 8 }) {
    LoadingProgress().width(20).height(20).color('#FF6B6B')
    Text('正在查最新价格...').fontSize(13).fontColor('#FF6B6B')
  }.height(60).justifyContent(FlexAlign.Center).width('100%')
}

一个容易踩的点:不支持代码触发刷新

想"进页面自动刷新一次"?组件不支持 programmatically 触发刷新动画。替代方案是进页面时静默加载数据:

typescript 复制代码
// ❌ 不支持
// controller.triggerRefresh()

// ✅ 替代:静默加载,不显示刷新动画
aboutToAppear() { this.loadProducts() }

private async loadProducts() {
  this.products = await fetchWishlist()
}

其他限制也要知道:容器只支持 List/Scroll/Tabs/Grid/WaterFlow;edgeEffect 必须设 None;不支持触底自动加载(要用户主动上拉);数据不满一屏时上拉加载不触发。


小结

"好物清单"这个场景里,@ohos/pulltorefresh 解决了三件事:下拉刷新查价让比价 App 的核心体验成立、BindSheet 弹窗里同样能刷新、多 Tab 各自独立互不干扰。

记住三条铁律就不会出问题:回调必须提供、必须返回 Promise、失败也要 resolve。剩下的交给组件------五种容器、自定义动画、LazyForEach 兼容,都是开箱即用。

相关推荐
贾伟康2 小时前
【鸿蒙心迹】个人开发者上架复盘:备案、合规与审核指南没写透的细节
华为·harmonyos
不爱学英文的码字机器4 小时前
2026鸿蒙远程控制软件完全选购指南:从入门到精通选型攻略
华为·harmonyos
贾伟康1 天前
【共创稿事节】HarmonyOS 7.0 BindContentCover全屏模态:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
KevinChaos1 天前
声音明信片实验室 03|HarmonyOS 7.0 音频空间化:双版本试听无缝切换
harmonyos
贾伟康1 天前
【共创稿事节】HarmonyOS 7.0 Menu菜单与上下文:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
行者-全栈开发1 天前
Flutter 插件 device_calendar_plus 开源鸿蒙适配实战:从权限弹窗失灵到循环日程跑通
flutter·跨平台·harmonyos·arkts·日历·适配·开源鸿蒙
贾伟康1 天前
【共创稿事节】HarmonyOS 7.0 BindSheet半模态浮层:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
JosieBook1 天前
【鸿蒙PC GUI适配】chiaki-ng 移植的工程实践:Qt6/QML 与 ArkUI 的混合窗口架构
华为·架构·harmonyos
Dream-Y.ocean2 天前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos