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 兼容,都是开箱即用。