Harmory Next 下拉刷新 悬浮loading效果

131 阅读4分钟

2025/08/04更新

优化了一下代码结构 动画改为Animator实现 并且附上了使用组件的代码
现在和原生Refresh组件的控制保持一致 外部可直接通过修改传入的状态变量来控制是否刷新
另外这个是下拉刷新的demo 不支持上拉加载


因为业务需要在下拉时列表不能滑动,而是一个悬浮式的loading跟手滑动,在全网找了一圈都没找到案例,只能自己动手写一个demo去试验 不说了 直接上效果吧

效果图(模拟器环境下)

demo.webp

核心代码

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')
  }
}