2025/08/04更新
优化了一下代码结构 动画改为Animator实现 并且附上了使用组件的代码
现在和原生Refresh组件的控制保持一致 外部可直接通过修改传入的状态变量来控制是否刷新
另外这个是下拉刷新的demo 不支持上拉加载
因为业务需要在下拉时列表不能滑动,而是一个悬浮式的loading跟手滑动,在全网找了一圈都没找到案例,只能自己动手写一个demo去试验 不说了 直接上效果吧
效果图(模拟器环境下)
核心代码
SuspensionRefresh.ets 组件内部代码
// SuspensionRefresh.ets 组件内部代码
import { AnimatorResult } from "@kit.ArkUI"
@Component
export struct SuspensionRefresh {
// scroll controller
private scrollController:Scroller = new Scroller()
// default slot
@BuilderParam content:() => void = this.defaultContent
// 刷新控制变量 必传
@Link @Watch('refreshHandler') isRefresh:boolean
// 旋转角度最大值
@Prop rotateAngleLimit:number = 360
// 下滑距离
@State pullOffsetY:number = -40
// 透明度
@State opacityValue:number = 0
// 是否正在下拉中
@State isPulling:boolean = false
// 进度条值
@State progressValue:number = 0
// 设置下拉距离多少时可触发刷新
refreshOffset:number = 64
// 设置下拉距离的极限
refreshOffsetLimit:number = 96
// 识别距离 即下拉多少时认为开始触发下拉动画
private pullRefreshStartLimit:number = 10
// 记录每一次触发滑动事件时 上一次和这一次的滑动距离
private currPullOffsetY:number = 0
private lastPullOffsetY:number = 0
// 比值
private radio:number = 0
// 内部内容是否触顶
private isReactPageByTop:boolean = true
// 容器旋转角度
@State rotateAngle:number = 0
// 进度条旋转角度
@State rotateProgressAngle:number = 0
// 刷新时动画 使用Animator
refreshAnimationResultByRotate?:AnimatorResult
refreshAnimationResultByProgress?:AnimatorResult
// callback start
onRefreshHandler: () => Promise<void> | void = async () => {}
// callback end
/**
* 创建动画
*/
createAnimator(){
this.refreshAnimationResultByRotate = this.getUIContext().createAnimator({
duration:800,
iterations:-1,
easing:'linear',
delay:0,
fill:'forwards',
direction:'normal',
begin:0,
end:360
})
this.refreshAnimationResultByProgress = this.getUIContext().createAnimator({
duration:800,
iterations:-1,
easing:'linear',
delay:0,
fill:'forwards',
direction:'alternate',
begin:0,
end:80
})
this.refreshAnimationResultByRotate.onFrame = (progress:number) => {
this.rotateProgressAngle = progress
}
this.refreshAnimationResultByProgress.onFrame = (progress:number) => {
this.progressValue = progress
}
}
/**
* 销毁动画
*/
destroyAnimator(){
this.refreshAfterHandler()
this.refreshAnimationResultByRotate = undefined
this.refreshAnimationResultByProgress = undefined
}
/**
* 刷新变量更改时触发 执行动画 + 执行刷新方法
*/
async refreshHandler(){
if (this.isRefresh) {
// 赋值到UI刷新状态
this.pullOffsetY = this.refreshOffsetLimit
this.rotateAngle = this.rotateAngleLimit
this.radio = 1
this.opacityValue = 1
// 开始执行动画
this.refreshAnimationResultByRotate?.play()
this.refreshAnimationResultByProgress?.play()
// 执行刷新方法
await this.onRefreshHandler()
}
else {
this.refreshAfterHandler()
}
}
/**
* 刷新结束后 方法
*/
refreshAfterHandler(){
// 刷新结束后
// 恢复初始状态
this.refreshAnimationResultByRotate?.pause()
this.refreshAnimationResultByProgress?.pause()
// this.isRefresh = false
this.isPulling = false
this.pullOffsetY = -40
this.opacityValue = 0
this.isReactPageByTop = true
this.refreshAnimationResultByRotate?.finish()
this.refreshAnimationResultByProgress?.finish()
}
/**
* 初始化方法 只在 aboutToAppear触发
*/
async init(){
this.createAnimator()
this.refreshHandler()
}
/**
* 销毁方法 只在aboutToDisappear触发
*/
async destroy(){
this.destroyAnimator()
}
/*
* lifecycle start
* */
aboutToAppear(): void {
this.init()
}
build() {
this.layout()
}
aboutToDisappear(): void {
this.destroy()
}
/*
* lifecycle end
* */
/*
* 布局builder
* */
@Builder
layout(){
Stack(){
Column(){
Scroll(this.scrollController){
this.content()
}
.layoutContainerStyleByScroll()
.enableScrollInteraction(!this.isPulling)
.onReachStart(() => {
// 标记已置顶
this.isReactPageByTop = true
})
.onDidScroll(() => {
if (this.scrollController.currentOffset().yOffset !== 0) {
this.isReactPageByTop = false
}
})
}
.full()
.hitTestBehavior(this.isRefresh ? HitTestMode.None : HitTestMode.Default)
// 并行手势
.parallelGesture(
// 滑动手势
PanGesture({fingers:1,distance:this.pullRefreshStartLimit,direction:PanDirection.Vertical})
// 手势开始时
.onActionStart(() => {
// 触发手势时 给一个默认值
this.currPullOffsetY = 0
this.lastPullOffsetY = 0
this.opacityValue = 0.5
this.pullOffsetY = -30
})
// 手势中
.onActionUpdate((event) => {
// 记录当前滑动位置 以及记录上一次的滑动位置
this.lastPullOffsetY = this.currPullOffsetY
this.currPullOffsetY = event.offsetY
// 计算比值
let radio = this.pullOffsetY / this.refreshOffsetLimit
// 判断下拉还是上拉
let isPullDown:boolean = this.currPullOffsetY - this.lastPullOffsetY > 0 ? true : false
// 如果此时触顶并下拉 则标记为下拉状态中
if (this.isReactPageByTop && isPullDown){
this.isPulling = true
}
// 刷新中 直接退出
if (this.isRefresh){
return
}
// 不在下拉状态中 直接退出
if (!this.isPulling){
return
}
// 下拉时
if (isPullDown){
// 此时如果超出下拉最大距离 则给一个固定值
if (this.pullOffsetY >= this.refreshOffsetLimit) {
this.pullOffsetY = this.refreshOffsetLimit
this.rotateAngle = this.rotateAngleLimit
}
// 如果没超出 则计算比值并按比例赋值
else {
this.pullOffsetY += (this.currPullOffsetY - this.lastPullOffsetY)
this.rotateAngle = radio * this.rotateAngleLimit
// 如果已经达到刷新距离但未到极限距离 则把透明度给一个默认值
if (this.pullOffsetY > this.refreshOffset) {
this.opacityValue = 1
}
// 如果此时已经缩回临界点 即 this.pullOffsetY < -30
else if(this.pullOffsetY < -30){
this.opacityValue = 0
}
else {
this.opacityValue = 0.5 + ((this.pullOffsetY / this.refreshOffset) / 2)
}
}
}
// 上拉时
else {
this.pullOffsetY += (this.currPullOffsetY - this.lastPullOffsetY)
this.rotateAngle = radio * this.rotateAngleLimit
// 如果已经达到刷新距离但未到极限距离 则把透明度给一个默认值
if (this.pullOffsetY > this.refreshOffset) {
this.opacityValue = 1
}
// 如果此时已经缩回临界点 即 this.pullOffsetY < -30
else if(this.pullOffsetY < -30){
this.opacityValue = 0
}
else {
this.opacityValue = 0.5 + ((this.pullOffsetY / this.refreshOffset) / 2)
}
}
})
// 手势结束时
.onActionEnd(async (event) => {
// 如果下拉距离已经超出 就赋值固定值
if (!this.isRefresh){
if (this.pullOffsetY > this.refreshOffset) {
this.isRefresh = true
}
else {
this.isRefresh = false
this.refreshAfterHandler()
}
}
})
// 取消手势
.onActionCancel(() => {
console.log('cancel')
})
)
// loading-icon builder
this.refreshLoadingBuilder()
}
.full()
.align(Alignment.Top)
.clip(true)
}
/*
* 占位builder
* */
@Builder
defaultContent(){
Column(){
// 可以放置通用空状态
}
.full()
}
/*
* 刷新loading
* */
@Builder
refreshLoadingBuilder(){
Column(){
if (this.isRefresh){
Progress({type:ProgressType.Ring,value:this.progressValue,total:100})
.width(20)
.height(20)
.color('#FF4C58')
.style({
strokeWidth:2,
})
.backgroundColor(Color.Transparent)
.rotate({
x:0,
y:0,
z:1,
angle:this.rotateProgressAngle
})
}
else {
SymbolGlyph($r('sys.symbol.arrow_clockwise'))
.fontSize(20)
.fontWeight(700)
.fontColor(['#FF4C58'])
}
}
.translate({
y:this.pullOffsetY
})
.rotate({
x:0,
y:0,
z:1,
angle:this.rotateAngle
})
.opacity(this.opacityValue)
.animation({
duration:100,
curve:Curve.Linear
})
.refreshLoadingContainerStyle()
}
}
// 样式-占满
@Styles
function full(){
.width('100%')
.height('100%')
}
// loading 容器 style
@Styles
function refreshLoadingContainerStyle(){
.borderRadius(20)
.backgroundColor('#FFFFFF')
.zIndex(100)
.padding(5)
}
// 内部滚动组件容器 style
@Extend(Scroll)
function layoutContainerStyleByScroll(){
.width('100%')
.height('100%')
.align(Alignment.Top)
.scrollBar(BarState.Off)
}
entry/index.ets
@Entry
@Component
struct Index {
@State arr: Array<string> = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'];
@State isRefresh:boolean = false
// 模拟接口
async getData():Promise<Array<string>>{
return new Promise((resolve,reject) => {
const arr:Array<string> = []
for (let i = 0 ; i < 15 ; i++){
arr[i] = Math.ceil(Math.random() * 100).toString()
}
setTimeout(() => {
resolve(arr)
},3500)
})
}
// 刷新
async refreshHandler(){
// 请求接口
this.arr = await this.getData()
}
async aboutToAppear(){
this.isRefresh = true
}
build() {
Column({space:16}){
Button('手动刷新')
.type(ButtonType.Normal)
.borderRadius(5)
.height(50)
.width('100%')
.onClick(() => {
this.isRefresh = true
})
Column(){
SuspensionRefresh({
isRefresh:this.isRefresh,
refreshOffset:32,
refreshOffsetLimit:64,
onRefreshHandler:async () => {
await this.refreshHandler()
this.isRefresh = false
}
}){
// 一个列表
List({space:16}){
ForEach(this.arr,(item:string,index) => {
ListItem(){
Column(){
Text(item)
}
.width('100%')
.height(60)
.borderRadius(8)
.backgroundColor(Color.Pink)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
})
}
.scrollBar(BarState.Off)
}
}
.layoutWeight(1)
}
.height('100%')
.width('100%')
.backgroundColor('#F2F2F2')
}
}