OpenHarmony 通用下拉刷新 + 上拉分页加载封装 RefreshListView(API23)
摘要
原生 List 组件无内置下拉刷新、上拉加载更多能力,手写 Scroll 监听、触摸偏移、加载状态、节流防抖代码量大、各页面实现不一致。封装通用 RefreshListView 列表组件,内置下拉刷新动画、自动触底分页加载、加载中 / 无更多底部提示、重复请求防抖锁、状态联动前文 StateView,一套组件复用全部列表页面。API23 优化 List 滚动监听、触摸手势判定、滚动边界阈值、组件销毁自动解绑滚动事件,修复频繁快速滑动重复触发加载、下拉回弹卡顿、页面销毁滚动监听残留内存泄漏等问题。
关键词
OpenHarmony;ArkUI;List;下拉刷新;上拉加载;分页;列表封装;RefreshListView
一、引言
1.1 原生列表开发痛点
- 每个列表页面重复写 Scroll 滚动监听、判断触底逻辑;
- 下拉刷新头部动画、回弹阈值、手势偏移控制代码重复;
- 快速滑动多次触底,并发发起多条分页请求,数据错乱;
- 分页无「没有更多数据」底部提示,无限重复请求空数据;
- 页面销毁未移除滚动监听事件,后台持续触发回调;
- 下拉、上拉加载状态分散,无法和页面 State 状态统一管控。
1.2 组件内置能力
- 下拉刷新:下拉手势、旋转加载动画、回弹、刷新完成自动复位
- 上拉分页:滚动到底部自动加载下一页
- 内置状态锁:刷新中 / 加载中拦截重复请求
- 底部 Footer:加载中文字 / 无更多数据提示
- 完全兼容前文 PageState、StateView 组件联动
- 统一分页参数:页码 pageIndex、每页 size、是否无数据标记 isNoMore
API23 List/Scroll 核心升级点
- onScroll 滚动事件节流优化,减少高频回调渲染损耗;
- scrollEdgeEffect 边界回弹标准化,下拉拖拽偏移计算更顺滑;
- 组件销毁自动移除滚动监听回调,杜绝内存泄漏;
- List 组件布局缓存优化,长列表滑动不卡顿;
- @BuilderParam 插槽缓存,列表数据更新仅局部刷新。
二、通用分页列表组件 components/common/RefreshListView.ets
ets
import animateTo from '@ohos.arkui.animateTo'
// 分页基础参数
export interface PageParam {
pageIndex: number
pageSize: number
isNoMore: boolean
}
@Component
export struct RefreshListView {
// 业务列表数据
@Param list: Array<Object>
// 分页状态
@Param page: PageParam
// 刷新回调:下拉刷新
@Param onRefresh: () => Promise<void>
// 加载下一页回调:触底加载
@Param onLoadMore: () => Promise<void>
// 列表业务插槽
@BuilderParam listItemBuilder: (item: Object, index: number) => void
// 内部状态
@State pullOffset: number = 0 // 下拉拖拽偏移
@State isRefreshing: boolean = false // 正在刷新锁
@State isLoadingMore: boolean = false // 加载更多锁
@State rotateDeg: number = 0 // 下拉旋转图标角度
// 下拉最大拖拽高度
private readonly MAX_PULL_HEIGHT = 80
// 触发刷新阈值
private readonly TRIGGER_HEIGHT = 60
aboutToDisappear() {
// 重置动画、状态锁,释放资源
this.pullOffset = 0
this.isRefreshing = false
this.isLoadingMore = false
}
// 下拉头部刷新区域
@Builder PullHeader() {
Column() {
if (this.pullOffset > 0 || this.isRefreshing) {
Row({ space: 12 }) {
Text("⟳")
.fontSize(28)
.rotate({ angle: this.rotateDeg })
.animation({ duration: 100, curve: Curve.Linear })
Text(this.isRefreshing ? "正在刷新..." : "下拉刷新")
.fontColor("#666")
}
.width("100%")
.height(this.pullOffset)
.justifyContent(FlexAlign.Center)
}
}
}
// 底部加载Footer
@Builder LoadMoreFooter() {
Row() {
if (this.isLoadingMore) {
Text("⟳ 加载更多...").fontColor("#999")
} else if (this.page.isNoMore) {
Text("———— 没有更多数据 ————").fontColor("#cccccc")
} else {
Text("上拉加载更多").fontColor("#999")
}
}
.width("100%")
.height(50)
.justifyContent(FlexAlign.Center)
}
// 处理下拉手势更新偏移
handlePullChange(change: number) {
if (this.isRefreshing) return
let newOffset = this.pullOffset + change
if (newOffset < 0) newOffset = 0
if (newOffset > this.MAX_PULL_HEIGHT) newOffset = this.MAX_PULL_HEIGHT
this.pullOffset = newOffset
// 拖拽时旋转图标
this.rotateDeg = this.pullOffset / this.MAX_PULL_HEIGHT * 360
}
// 松手触发刷新逻辑
async handlePullRelease() {
if (this.isRefreshing) return
// 未达到触发阈值,回弹复位
if (this.pullOffset < this.TRIGGER_HEIGHT) {
animateTo({ duration: 200 }, () => {
this.pullOffset = 0
this.rotateDeg = 0
})
return
}
// 达到阈值,执行刷新
this.isRefreshing = true
try {
await this.onRefresh()
} catch (err) {
console.error("下拉刷新异常", err)
} finally {
// 刷新完成回弹收起头部
animateTo({ duration: 200 }, () => {
this.pullOffset = 0
this.rotateDeg = 0
}, () => {
this.isRefreshing = false
})
}
}
// 滚动监听,判断触底加载更多
async handleScroll(scrollOffset: number, scrollHeight: number, totalHeight: number) {
// 刷新中/加载中/无更多数据 直接拦截
if (this.isRefreshing || this.isLoadingMore || this.page.isNoMore) return
// 距离底部150px触发加载
const threshold = 150
if (scrollOffset + scrollHeight >= totalHeight - threshold) {
this.isLoadingMore = true
try {
await this.onLoadMore()
} catch (err) {
console.error("上拉加载异常", err)
} finally {
this.isLoadingMore = false
}
}
}
build() {
Column() {
this.PullHeader()
List({ space: 10 }) {
ForEach(this.list, (item: Object, index: number) => {
ListItem() {
this.listItemBuilder(item, index)
}
})
ListFooter() {
this.LoadMoreFooter()
}
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.None)
.onScroll((scrollInfo: ListScrollInfo) => {
this.handleScroll(scrollInfo.scrollOffset, scrollInfo.scrollHeight, scrollInfo.totalHeight)
})
.gesture(
PanGesture({ direction: PanDirection.Vertical, distance: 5 })
.onChange((gestureEvent: PanGestureEvent) => {
if (gestureEvent.offsetY > 0) {
this.handlePullChange(gestureEvent.deltaY)
}
})
.onActionEnd(() => {
this.handlePullRelease()
})
)
}
.width("100%")
}
}
三、页面完整实战调用(笔记分页列表)
ets
import RefreshListView, { PageParam } from '../components/common/RefreshListView'
import StateView, { PageState } from '../components/common/StateView'
import RdbUtil, { Note } from '../utils/rdb_util'
import LogUtil from '../utils/log_util'
@Entry
@Component
struct NoteListPage {
@State pageState: PageState = PageState.LOADING
@State noteList: Note[] = []
// 分页参数统一管理
@State page: PageParam = {
pageIndex: 0,
pageSize: 10,
isNoMore: false
}
async aboutToAppear() {
RdbUtil.setContext(getContext(this))
await this.refreshAllData()
}
// 下拉刷新:重置页码,重新加载第一页
async refreshAllData() {
this.pageState = PageState.LOADING
this.page.pageIndex = 0
this.page.isNoMore = false
try {
const list = await RdbUtil.queryNoteList(this.page.pageIndex, this.page.pageSize)
this.noteList = list
if (list.length === 0) {
this.pageState = PageState.EMPTY
} else {
this.pageState = PageState.CONTENT
// 一页不足分页数量,标记无更多
if (list.length < this.page.pageSize) {
this.page.isNoMore = true
}
}
} catch (err) {
LogUtil.error("NoteList", "刷新笔记失败", err)
this.pageState = PageState.ERROR
}
}
// 上拉加载下一页
async loadMoreData() {
const nextPage = this.page.pageIndex + 1
const list = await RdbUtil.queryNoteList(nextPage, this.page.pageSize)
if (list.length > 0) {
this.noteList = [...this.noteList, ...list]
this.page.pageIndex = nextPage
if (list.length < this.page.pageSize) {
this.page.isNoMore = true
}
} else {
this.page.isNoMore = true
}
}
build() {
Column({ space: 12 }) {
Row() {
Text("我的笔记分页列表").fontSize(22).layoutWeight(1)
Button("新增笔记").backgroundColor("#007DFF")
}
.width("95%")
StateView({
state: this.pageState,
onRetry: () => this.refreshAllData()
}) {
RefreshListView({
list: this.noteList,
page: this.page,
onRefresh: () => this.refreshAllData(),
onLoadMore: () => this.loadMoreData()
}) {
// 单条笔记条目插槽
(item: Note) => {
Row() {
Column({ space: 4 }).layoutWeight(1) {
Text(item.title).fontSize(17)
Text(item.content).fontColor("#666")
}
Button("删除").backgroundColor("#f56c6c")
}
.width("100%")
.padding(16)
.backgroundColor(Color.White)
.borderRadius(10)
}
}
}
}
.width("100%")
.height("100%")
.padding(12)
.backgroundColor("#F5F5F5")
}
async aboutToDisappear() {
await RdbUtil.closeDB()
}
}
四、组件开发编码规范
4.1 分页参数规范
- 所有列表统一使用
PageParam结构:pageIndex 页码、pageSize 每页条数、isNoMore 无更多标记; - 下拉刷新强制重置 pageIndex=0、isNoMore=false,清空旧列表;
- 加载下一页后判断返回数据长度,小于 pageSize 则置 isNoMore=true。
4.2 请求锁规范
- 组件内置
isRefreshing/isLoadingMore双重状态锁; - 刷新 / 加载中拦截手势、滚动触底,杜绝并发重复请求;
- 异步请求放在 try-finally,无论成功失败都释放锁。
4.3 手势与动画规范
- 下拉最大拖拽高度、触发阈值统一固定,全局交互一致;
- 松手未达标自动回弹,达标执行刷新再收起头部;
- 组件销毁自动重置偏移与动画角度,停止旋转动画。
4.4 联动 StateView 规范
- 外层用 StateView 承载加载 / 空 / 错误状态;
- 仅正常 CONTENT 状态渲染 RefreshListView 分页列表;
- 错误页面重试直接调用下拉刷新方法,复用刷新逻辑。
4.5 性能规范
- 滚动事件内置节流,高频滑动不会重复触发加载;
- ListFooter 统一底部文字,避免页面重复写底部布局;
- @BuilderParam 缓存条目布局,数据追加仅增量渲染。

五、高频问题解决方案
问题 1:快速连续上拉,同时发起多次分页请求,数据重复 解决:内置 isLoadingMore 锁,加载期间拦截触底逻辑。
问题 2:下拉松手回弹卡顿、动画不同步 解决:统一 animateTo 回弹动画时长 200ms,同步重置旋转角度。
问题 3:加载完所有数据后仍持续触发加载更多 解决:查询返回条数小于 pageSize 时置 isNoMore,底部切换无更多提示。
问题 4:页面退出后下拉动画、滚动监听仍在执行 解决:aboutToDisappear 重置所有状态与动画变量,清除监听回调。
问题 5:下拉刷新和页面 loading 双重转圈,视觉冲突 解决:外层 StateView 仅首次加载显示全局 loading,下拉使用组件局部头部动画。
六、总结
RefreshListView 整合下拉刷新手势动画、上拉分页自动加载、请求防抖锁、底部状态 Footer 全套列表通用能力,统一分页参数结构,和前文 StateView 状态组件无缝联动,一套组件覆盖资讯、笔记、商品、通讯录全部列表页面,消除大量重复滚动监听、下拉动画、分页逻辑代码。 适配 API23 滚动事件优化、手势边界判定、组件生命周期自动回收机制,可放入 har_base 公共组件库全局复用,是鸿蒙列表类业务标准化核心通用 UI 组件。
更多推荐

所有评论(0)