HarmonyOS 实现下拉刷新,上拉加载更多
组件介绍
PullToRefreshList
允许用户通过下拉动作来刷新列表内容,以及通过上拉动作来加载更多的数据。组件内部封装了滚动监听、状态管理和动画效果,使得开发者可以轻松集成到自己的项目中。
1. 实现思路
- 封装成可复用的公共控件:将下拉刷新和上拉加载更多功能封装为一个可复用的组件,便于在不同列表场景中应用。
- 状态管理:使用状态变量来管理刷新状态(如
refreshing
、loadingMoreIng
)、列表是否滑动到顶部(isAtTopOfList
)等。 - 事件回调:通过定义回调函数(如
onRefresh
、onLoadMore
、onStatusChanged
)来处理刷新和加载逻辑,并与外部逻辑解耦。 - 手势识别:利用触摸事件(
TouchType.Down
、TouchType.Move
、TouchType.Up
)来识别用户的下拉的手势。 - 动画效果:通过控制滚动偏移量和刷新头部的显示与隐藏,实现平滑的下拉刷新动画效果。
3. 下拉刷新实现原理
下拉刷新的实现依赖于对触摸事件的监听和处理:
- 触摸按下(
TouchType.Down
):记录用户触摸按下时的坐标,用于后续的滑动判断。 - 触摸移动(
TouchType.Move
):在用户移动手指时,计算移动的距离,并根据列表的当前滚动位置和偏移量来判断是否触发下拉刷新的动作。若用户在列表顶部下拉,则通过更新offsetY
来控制刷新头部的显示和隐藏,同时更新刷新状态。 - 触摸抬起或取消(
TouchType.Up
或TouchType.Cancel
):在用户完成下拉动作后,根据偏移量判断是否满足刷新条件。如果满足,则触发刷新逻辑,并通过回调函数onRefresh
通知外部进行数据刷新。如果不满足,则恢复列表到未拖动状态。
4. 加载更多实现原理
- 滚动到底部:使用
onReachEnd
事件监听器来检测用户是否滚动到列表底部。当触发此事件时,如果loadingMoreIng
为false
,表示当前没有在加载更多数据,则将其设置为true
,开始加载更多数据。 - 加载逻辑:调用
onLoadMore
回调函数来执行加载更多的逻辑。在加载数据的过程中,可以更新列表项的布局以显示加载提示文本,如“努力加载中...”。 - 数据更新:加载完成后,更新数据源
dataSet
,并重置loadingMoreIng
为false
,表示加载更多操作已完成。
PullToRefreshList完整代码:
import { Constant } from '../constant/Constant';
@Preview
@Component
export struct PullToRefreshList {
// 通过BuilderParam装饰器声明的函数参数,用于自定义列表项的布局
@BuilderParam
itemLayout?: (item: Object, index: number) => void;
// 通过Watch和Link装饰器监听刷新状态的变化,并双向绑定刷新标志
@Watch("notifyRefreshingChanged")
@Link refreshing: boolean;
// 通过Link装饰器双向绑定,表示是否正在加载更多数据
@Link
loadingMoreIng: boolean;
// 状态变量,表示列表是否滑动到顶部
@State
isAtTopOfList: boolean = false;
// 通过Link装饰器双向绑定,表示列表的数据源
@Link dataSet: Array<Object>;
// 定义回调函数,用于处理刷新和加载更多事件
onRefresh?: () => void;
onLoadMore?: () => void;
// 定义回调函数,用于处理刷新状态变化事件
onStatusChanged?: (status: RefreshStatus) => void;
// 私有成员变量,定义下拉刷新头部的高度
private headHeight: number = 55;
// 私有成员变量,用于记录触摸事件的坐标
private lastX: number = 0;
private lastY: number = 0;
private downY: number = 0;
// 私有成员变量,用于下拉刷新时的动画效果和手势识别
private flingFactor: number = 0.75;
private touchSlop: number = 2;
private offsetStep: number = 10;
private intervalTime: number = 20;
// 私有成员变量,控制列表是否可以滚动
private listScrollable: boolean = true;
// 私有成员变量,标识是否正在拖动列表
private dragging: boolean = false;
// 私有成员变量,当前刷新状态
private refreshStatus: RefreshStatus = RefreshStatus.Inactive;
// 通过Watch和State装饰器监听并状态绑定,表示下拉刷新头部的偏移量
@Watch("notifyOffsetYChanged")
@State offsetY: number = -this.headHeight;
// 状态变量,刷新头部图标资源
@State refreshHeadIcon: Resource = $r("app.media.icon_refresh_down");
// 状态变量,刷新头部提示文本
@State refreshHeadText: string = Constant.REFRESH_PULL_TO_REFRESH;
// 状态变量,刷新内容区域的高度
@State refreshContentH: number = 0;
// 状态变量,控制组件是否可以接收触摸事件
@State touchEnabled: boolean = true;
// 状态变量,控制刷新头部的可见性
@State headerVisibility: Visibility = Visibility.None;
// 私有成员变量,滚动器对象,用于控制滚动行为
private listScroller: Scroller = new Scroller();
/**
* 当刷新状态变化时调用的方法
*/
private notifyRefreshingChanged() {
// 根据刷新标志显示刷新状态或完成刷新
if (this.refreshing) {
this.showRefreshingStatus();
} else {
this.finishRefresh();
}
}
/**
* 当下拉刷新头部偏移量变化时调用的方法
*/
private notifyOffsetYChanged() {
// 根据偏移量设置刷新头部的可见性
this.headerVisibility = (this.offsetY == -this.headHeight) ? Visibility.None : Visibility.Visible;
}
/**
* 构建刷新头部组件的逻辑
*/
@Builder
RefreshHead() {
// 使用Row布局来水平排列图标和文本
Row() {
Blank()
Image(this.refreshHeadIcon)
.width(30)
.aspectRatio(1) // 保持图片宽高比
.objectFit(ImageFit.Contain) // 保持图片内容完整
Text(this.refreshHeadText)
.fontSize(16)
.width(150)
.textAlign(TextAlign.Center)
Blank()
}
.width("100%")
.height(this.headHeight)
.backgroundColor("#44bbccaa") // 设置背景颜色
.visibility(this.headerVisibility) // 根据状态设置可见性
.position({ // 设置位置
x: 0,
y: this.offsetY
})
}
/**
* 构建刷新内容组件的逻辑
*/
@Builder
RefreshContent() {
List({ scroller: this.listScroller }) { // 使用List组件创建滚动列表,并传入滚动器
if (this.dataSet) { // 判断数据源是否存在
ForEach(this.dataSet, (item: Object, index: number) => { // 遍历数据源并为每项创建列表项
ListItem() {
if (this.itemLayout) { // 如果提供了列表项布局函数,则使用它
this.itemLayout(item, index)
}
}
.width("100%"); // 设置列表项宽度
}, (item: Object, index: number) => item.toString()) // 为列表项提供唯一的标识符
// 上拉加载更多的UI提示
ListItem() {
if (this.loadingMoreIng === false) {
Text('努力加载中...') // 显示加载文本
.width('100%') // 设置文本宽度
.height(100) // 设置文本高度
.fontSize(16) // 设置字体大小
.textAlign(TextAlign.Center) // 设置文本居中对齐
.backgroundColor(0xDCDCDC); // 设置背景颜色
}
}
}
}
.width("100%") // 设置列表宽度
.height("100%") // 设置列表高度
.edgeEffect(EdgeEffect.None) // 设置无边缘效果
.onScrollFrameBegin((offset: number, state: ScrollState) => { // 监听滚动事件
offset = this.listScrollable ? offset : 0; // 根据滚动状态决定是否允许滚动
return { offsetRemain: offset }
})
.onReachEnd(() => { // 监听滚动到列表底部的事件
if (!this.loadingMoreIng) { // 如果不是正在加载更多
this.loadingMoreIng = true // 设置为正在加载状态
this.onLoadMore() // 调用加载更多的回调函数
this.loadingMoreIng = false // 完成加载,设置为非加载状态
}
})
.onScrollIndex((start: number, end: number) => { // 监听滚动索引变化事件
this.logD("onScrollIndex() start = " + start + ",end = " + end) // 打印滚动索引
// 根据滚动索引判断列表是否滑动到顶部
if (start == 0) {
this.isAtTopOfList = true
} else {
this.isAtTopOfList = false
}
})
}
// 定义组件的构建逻辑,使用 Column 组件作为根布局
build() {
// 创建一个 Column 组件实例,作为整个下拉刷新列表的容器
Column() {
// 调用 RefreshHead 方法,添加下拉刷新的头部组件
this.RefreshHead()
// 创建另一个 Column 组件实例,作为内容区域的容器
Column() {
// 调用 RefreshContent 方法,添加可滚动的内容区域
// 这个内容区域包含了列表数据的展示
this.RefreshContent()
}
// 为内容区域的 Column 设置属性
.id("refresh_content") // 设置组件的 ID
.width("100%") // 设置宽度为100%,占满父容器宽度
.layoutWeight(1) // 设置布局权重,影响在剩余空间中的占比
.backgroundColor(Color.Pink) // 设置背景颜色为粉红色
.position({ // 设置内容区域的起始位置,实现下拉刷新时的上移效果
x: 0, // 在水平方向上的位置
y: this.offsetY + this.headHeight // 在垂直方向上的位置,根据偏移量和头部高度计算
})
}
// 为整个下拉刷新列表的 Column 设置属性
.id("refresh_list") // 设置根容器的 ID
.width("100%") // 设置宽度为100%,占满父容器宽度
.height("100%") // 设置高度为100%,占满父容器高度
.enabled(this.touchEnabled) // 设置是否启用触摸事件,基于 touchEnabled 状态变量
.onAreaChange((oldArea, newAre) => { // 监听容器大小变化事件
console.log("Refresh height: " + newAre.height); // 打印新的高度值
this.refreshContentH = Number(newAre.height); // 更新内容区域的高度状态变量
})
.clip(true) // 设置是否启用裁剪,超出部分会被隐藏
.onTouch((event) => { // 监听触摸事件
if (event.touches.length != 1) { // 判断是否为单指触摸
console.log("TOUCHES LENGTH INVALID: " + JSON.stringify(event.touches)); // 如果不是单指触摸,则打印错误并阻止事件传播
event.stopPropagation(); // 阻止事件继续传播
return; // 退出当前事件处理函数
}
// 根据触摸事件的类型执行相应的处理函数
switch (event.type) {
case TouchType.Down:
this.onTouchDown(event); // 处理触摸按下事件
break;
case TouchType.Move:
this.onTouchMove(event); // 处理触摸移动事件
break;
case TouchType.Up:
case TouchType.Cancel:
this.onTouchUp(event); // 处理触摸抬起或取消事件
break;
}
// 在所有触摸事件处理完毕后,阻止事件继续传播
event.stopPropagation();
})
}
/**
* 设置下拉刷新的状态,并根据状态启用或禁用触摸事件,以及通知刷新状态变化。
* @param status 下拉刷新的新状态。
*/
private setRefreshStatus(status: RefreshStatus) {
// 更新当前的刷新状态为传入的参数status
this.refreshStatus = status;
// 根据刷新状态设置refreshing标志,如果状态是Refresh,则设置为true,否则为false
this.refreshing = (status == RefreshStatus.Refresh);
// 根据刷新状态设置touchEnabled,当状态不是Refresh和Done时,允许触摸事件
this.touchEnabled = (status != RefreshStatus.Refresh && status != RefreshStatus.Done);
// 通知刷新状态发生了变化,调用onStatusChanged回调函数(如果已设置)
this.notifyStatusChanged();
}
/**
* 检查当前滚动状态是否允许触发下拉刷新动作。
* @returns {boolean} 如果列表滚动到顶部或已标记为顶部,则返回true,否则返回false。
*/
private canRefresh() {
// 检查滚动器的当前垂直偏移量的y坐标是否等于-headHeight,或者isAtTopOfList标志为true
// 如果列表已滚动到顶部(yOffset为-headHeight,即刷新头部的原始隐藏位置),
// 或者isAtTopOfList被设置为true(通过滚动事件监听器),则可以进行下拉刷新
return this.listScroller.currentOffset().yOffset == -this.headHeight || this.isAtTopOfList;
}
/**
* 处理触摸按下事件的方法。
* @param event 触摸事件对象,包含触摸点的相关信息。
*/
private onTouchDown(event: TouchEvent) {
// 从触摸事件对象中获取第一个触摸点的屏幕坐标
this.lastX = event.touches[0].screenX;
this.lastY = event.touches[0].screenY;
// downY用于记录初始按下时的Y坐标,用于后续移动事件中的滑动判断
this.downY = this.lastY;
}
/**
* 处理触摸移动事件的方法。
* @param event 触摸事件对象,包含触摸点的相关信息。
*/
private onTouchMove(event: TouchEvent) {
// 获取当前触摸点的屏幕坐标
let currentX = event.touches[0].screenX;
let currentY = event.touches[0].screenY;
// 计算自上次触摸移动以来的X、Y方向变化量
let deltaX = currentX - this.lastX;
let deltaY = currentY - this.lastY;
// 如果当前正处于拖动状态
if (this.dragging) {
// 记录当前偏移量
console.log("offsetY: " + this.offsetY.toFixed(2) + ", head: " + (-this.headHeight));
// 判断Y方向的滑动方向
if (deltaY < 0) {
// 向上拖动
if (this.offsetY > -this.headHeight) {
// 如果当前偏移量大于-headHeight,表示还在下拉刷新的可拖动范围内
console.log("手指向上拖动还未到达临界值,不让 list 滚动")
// 更新offsetY,并应用flingFactor作为滑动速度的调整因子
this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
// 此时不允许列表滚动
this.listScrollable = false;
} else {
// 如果当前偏移量小于等于-headHeight,表示已经到达临界值,可以开始滚动列表
console.log("手指向上拖动到达临界值了,开始让 list 滚动")
this.offsetY = -this.headHeight;
// 允许列表滚动
this.listScrollable = true;
// 重置downY为当前的lastY,为下次拖动做准备
this.downY = this.lastY;
}
} else {
// 向下拖动
console.log("手指向下拖动中 this.canRefresh() = " + this.canRefresh())
// 如果可以刷新(即滚动到了列表顶部)
if (this.canRefresh()) {
// 更新offsetY,并应用flingFactor作为滑动速度的调整因子
this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
// 此时不允许列表滚动
this.listScrollable = false;
} else {
// 如果不是在顶部,则允许滚动
this.listScrollable = true;
}
}
// 更新lastX和lastY为当前坐标,为下次滑动做准备
this.lastX = currentX;
this.lastY = currentY;
} else {
// 如果当前不在拖动状态
// 判断是否为向下的滑动,并且滑动的起始点是列表顶部
if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > this.touchSlop) {
if (deltaY > 0 && this.canRefresh()) {
// 设置拖动状态为true
this.dragging = true;
// 不允许列表滚动
this.listScrollable = false;
// 更新lastX和lastY为当前坐标
this.lastX = currentX;
this.lastY = currentY;
console.log("Touch MOVE: 手指向下滑动,达到了拖动条件");
}
}
}
// 如果当前正处于拖动状态
if (this.dragging) {
// 判断当前触摸点的Y坐标是否大于初始按下时的Y坐标
if (currentY >= this.downY) {
// 根据当前的offsetY值判断刷新头部的显示状态
if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
// 如果已经下拉到超过头部高度的80%,则显示松开刷新的提示
this.refreshHeadText = Constant.REFRESH_FREE_TO_REFRESH;
this.refreshHeadIcon = $r("app.media.icon_refresh_up");
// 设置当前刷新状态为OverDrag,即超过拖动区域的状态
this.setRefreshStatus(RefreshStatus.OverDrag);
} else {
// 如果还在下拉过程中,显示下拉刷新的提示
this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
this.refreshHeadIcon = $r("app.media.icon_refresh_down");
// 设置当前刷新状态为Drag,即拖动状态
this.setRefreshStatus(RefreshStatus.Drag);
}
}
}
// 可选的控制台日志,打印当前触摸点的坐标和偏移量,可以用于调试
// console.log("Touch MOVE: " + event.touches[0].screenX + " x " + event.touches[0].screenY + ", offset: " + this.offsetY);
}
/**
* 处理触摸抬起事件的方法,根据拖动状态和偏移量决定是否触发刷新。
* @param event 触摸事件对象,包含触摸点的相关信息。
*/
private onTouchUp(event: TouchEvent) {
// 打印抬起时的X、Y坐标和当前偏移量offsetY
console.log("Touch UP: " + event.touches[0].screenX.toFixed(2) +
" x " + event.touches[0].screenY.toFixed(2) +
", offset: " + this.offsetY);
// 如果当前状态为拖动状态,处理释放后的逻辑
if (this.dragging) {
// 判断是否满足下拉刷新的条件:offsetY大于等于0,或者下拉距离已经超过头部高度的80%
if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
// 打印日志,表示用户已经下拉到足够的距离,可以触发刷新
console.log("Touch UP: 触发下拉刷新条件");
// 更新刷新头部的图标和文本,提示用户正在刷新
this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
this.refreshHeadText = Constant.REFRESH_REFRESHING;
// 设置刷新状态为Refresh,表示正在刷新中
this.setRefreshStatus(RefreshStatus.Refresh);
// 将列表滚动到顶部,隐藏刷新头部
this.scrollToTop();
// 通知刷新开始,调用外部提供的onRefresh回调函数
this.notifyRefreshStart();
} else {
// 如果没有达到刷新条件,打印日志说明
console.log("Touch UP: 未达到下拉刷新条件");
// 更新刷新头部的图标和文本,提示用户下拉以刷新
this.refreshHeadIcon = $r("app.media.icon_refresh_down");
this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
// 设置刷新状态为Drag,表示用户可以继续拖动
this.setRefreshStatus(RefreshStatus.Drag);
// 滚动回原位,恢复到未拖动状态
this.scrollByTop();
}
}
}
/**
* 将滚动偏移量设置为0,使得内容区域回到顶部。
*/
private scrollToTop() {
// 设置滚动偏移量为0,滚动到列表顶部
this.offsetY = 0;
}
/**
* 滚动内容区域回到初始位置。
*/
private scrollByTop() {
// 如果当前滚动偏移量不等于-headHeight,即没有滚动到顶部
if (this.offsetY != -this.headHeight) {
// 记录开始滚动时的偏移量
this.logD("scrollByTop() start, offsetY: " + this.offsetY.toFixed(2));
// 使用setInterval创建一个滚动动画,每隔intervalTime毫秒更新一次偏移量
let intervalId = setInterval(() => {
// 如果滚动偏移量小于等于-headHeight,即滚动到了顶部
if (this.offsetY <= -this.headHeight) {
// 重置刷新状态
this.resetRefreshStatus();
// 清除定时器,停止滚动动画
clearInterval(intervalId);
// 记录滚动完成时的偏移量
this.logD("scrollByTop() finish, offsetY: " + this.offsetY.toFixed(2));
} else {
// 如果还没有滚动到顶部,则更新滚动偏移量
// 每次向上滚动offsetStep像素,直到滚动到头
this.offsetY = ((this.offsetY - this.offsetStep) < -this.headHeight) ?
(-this.headHeight) : (this.offsetY - this.offsetStep);
}
}, this.intervalTime);
} else {
// 如果已经滚动到顶部,则无需执行滚动操作
this.logD("scrollByTop(): already scrolled to top edge");
}
}
/**
* 重置刷新状态,将刷新头部恢复到初始状态。
*/
private resetRefreshStatus() {
// 设置滚动偏移量为-headHeight,即刷新头部隐藏的状态
this.offsetY = -this.headHeight;
// 设置刷新头部图标为默认图标
this.refreshHeadIcon = $r("app.media.icon_refresh_down");
// 设置刷新头部文本为默认文本
this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
// 调用setRefreshStatus方法,设置刷新状态为Inactive,即未激活状态
this.setRefreshStatus(RefreshStatus.Inactive);
}
/**
* 完成刷新操作后调用的方法,用于更新UI状态并滚动回列表顶部。
*/
private finishRefresh(): void {
// 更新刷新头部的文本和图标,表示刷新成功
this.refreshHeadText = Constant.REFRESH_SUCCESS;
this.refreshHeadIcon = $r("app.media.icon_refresh_success");
// 设置刷新状态为Done,表示刷新已完成
this.setRefreshStatus(RefreshStatus.Done);
// 延迟1500毫秒后滚动回列表顶部,以便用户可以看到刷新效果
setTimeout(() => {
this.scrollByTop();
}, 1500);
}
/**
* 组件即将显示时调用的方法,用于检查是否需要显示刷新状态。
*/
aboutToAppear() {
// 如果当前正在刷新(由refreshing标志控制)
if (this.refreshing) {
// 显示刷新状态,更新UI以反映刷新正在进行中
this.showRefreshingStatus();
}
}
/**
* 显示刷新状态的方法,用于更新UI以反映刷新正在进行中。
*/
private showRefreshingStatus() {
// 将滚动偏移量设置为0,确保刷新头部可见
this.offsetY = 0;
// 更新刷新头部的图标为加载图标
this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
// 更新刷新头部的文本为刷新中文本
this.refreshHeadText = Constant.REFRESH_REFRESHING;
// 设置刷新状态为Refresh,表示正在刷新
this.setRefreshStatus(RefreshStatus.Refresh);
}
/**
* 通知刷新开始的方法,用于调用外部提供的刷新回调函数。
*/
private notifyRefreshStart() {
// 如果提供了onRefresh回调函数
if (this.onRefresh) {
// 调用onRefresh回调函数,开始执行刷新逻辑
this.onRefresh();
}
}
/**
* 通知刷新状态变化的方法,用于在刷新状态变化时调用外部提供的回调函数。
*/
private notifyStatusChanged() {
// 如果提供了onStatusChanged回调函数
if (this.onStatusChanged) {
// 调用onStatusChanged回调函数,通知刷新状态的变化
this.onStatusChanged(this.refreshStatus);
}
}
private logD(msg: string) {
console.log(msg + ", canRefresh: " + this.canRefresh() + ", dragging: " + this.dragging + ", listScrollable: " + this.listScrollable + ", refreshing: " + this.refreshing);
}
}
调用示例:
import { PullToRefreshList } from '../view/PullToRefreshList';
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
// 初始列表数据
@State
dataSet: Array<string> = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
@State
refreshing: boolean = false //下拉刷新
@State
loading: boolean = false //上拉加载
build() {
Column() {
PullToRefreshList({
refreshing: $refreshing,
loadingMoreIng:$loading,
dataSet: $dataSet,
itemLayout: (item: Object, index: number) => {
this.item(item);
},
onRefresh: () => {
this.onRefresh();
},
onLoadMore: () => {
this.onLoadMore();
},
onStatusChanged: (status) => {
console.log("current status: " + status);
}
})
}.height('100%')
}
async onRefresh() {
setTimeout(() => {
console.log("finish refresh")
this.dataSet = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
this.refreshing = false;
}, 2500);
}
async onLoadMore() {
setTimeout(() => {
console.log("finish load More")
// 生成10条0到9之间的随机数并添加到dataSet数组中
for (let i = 0; i < 10; i++) {
// 生成一个0到9之间的随机数
const randomNumber = Math.floor(this.dataSet.length + 1);
// 将随机数添加到dataSet数组中
this.dataSet.push(randomNumber.toString());
}
this.loading = false
}, 2500);
}
//实现真正的布局 item
@Builder
item(item: Object) {
Text('' + item) // 显示列表项文本
.width('100%') // 设置文本宽度
.height(100) // 设置文本高度
.fontSize(24) // 设置字体大小
.textAlign(TextAlign.Center) // 设置文本居中对齐
.borderRadius(10) // 设置边框圆角
.backgroundColor(0xDCDCDC); // 设置背景颜色
}
}