DEV Community

HarmonyOS
HarmonyOS

Posted on

Seamless Pull-to-Refresh on HarmonyOS: Custom Lottie Header Without Content Shift

Read the original article:Seamless Pull-to-Refresh on HarmonyOS: Custom Lottie Header Without Content Shift

Top-Fixed Pull-to-Refresh on HarmonyOS (ArkUI) with Lottie (Content Stays Put)

Requirement Description

Implement a pull-to-refresh where the page content does not move while pulling. The refresh animation must remain fixed at the very top and play during the refresh.

Background Knowledge

Implementation Steps

1.Install Lottie package

   ohpm install @ohos/lottie
Enter fullscreen mode Exit fullscreen mode

2.Add a Lottie JSON
Place your animation.json (e.g., from the sample set) under resources/rawfile/common/lottie/animation.json.

3.Create a custom Refresh header (builder)

  • Render a Canvas at the top inside the Refresh builder.
  • Initialize/destroy the Lottie player in onReady / onDisAppear.
  • Use constraintSize({ minHeight }) to keep a minimum header height so the header won’t collapse.

4.Wire Refresh lifecycle

5.Keep content from shifting

  • Use the Refresh builder as a fixed top overlay (via Stack + clip(true)), and do not animate the List’s position/size.
  • Maintain List height at 100% so only the header animates while content remains visually stationary.

6.Tune thresholds

  • pullToRefresh(true) and refreshOffset(64) (or smaller on wearables) to control trigger distance and keep interactions crisp.

Code Snippet

import lottie, { AnimationItem } from '@ohos/lottie'

@Entry
@Component
export struct Index {
  // UI state
  @State private isRefreshing: boolean = false
  @State private items: string[] = this.generateItems()
  @State private isAnimating: boolean = false

  // Lottie
  private renderSettings: RenderingContextSettings = new RenderingContextSettings(true)
  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings)
  private animationItem: AnimationItem | null = null
  private animationName: string = 'refresh_animation'

  // Wearable-friendly header size / trigger distance
  private readonly headerMinHeight: number = 80  // vp
  private readonly triggerOffset: number = 80    // vp

  // ----- Helper: Generate items -----
  private generateItems(): string[] {
    const result: string[] = []
    for (let i = 0; i < 20; i++) {
      result.push(`Item ${i + 1}`)
    }
    return result
  }

  // ----- Lottie helpers -----
  private loadLottieAnimation(): void {
    if (this.animationItem !== null) {
      return
    }

    try {
      console.info('[Lottie] Loading animation...')

      this.animationItem = lottie.loadAnimation({
        container: this.canvasContext,
        renderer: 'canvas',
        loop: true,
        autoplay: false,
        name: this.animationName,
        path: 'common/lottie/animation.json'
      })

      if (this.animationItem) {
        this.animationItem.addEventListener('DOMLoaded', () => {
          console.info('[Lottie] Animation loaded')
          this.startAnimation()
        })

        this.animationItem.addEventListener('loopComplete', () => {
          console.info('[Lottie] Loop completed')
        })
      }
    } catch (err) {
      console.error('[Lottie] Load error: ' + JSON.stringify(err))
    }
  }

  private startAnimation(): void {
    if (this.animationItem && !this.isAnimating) {
      try {
        console.info('[Lottie] Starting animation')
        this.animationItem.play()
        this.isAnimating = true
      } catch (err) {
        console.error('[Lottie] Play error: ' + JSON.stringify(err))
      }
    }
  }

  private stopAnimation(): void {
    if (this.animationItem && this.isAnimating) {
      try {
        console.info('[Lottie] Stopping animation')
        this.animationItem.stop()
        this.isAnimating = false
      } catch (err) {
        console.error('[Lottie] Stop error: ' + JSON.stringify(err))
      }
    }
  }

  private destroyLottieAnimation(): void {
    try {
      if (this.animationItem) {
        console.info('[Lottie] Destroying animation')
        this.animationItem.destroy()
        this.animationItem = null
        this.isAnimating = false
      }

      lottie.destroy(this.animationName)
    } catch (err) {
      console.warn('[Lottie] Destroy warning: ' + JSON.stringify(err))
    }
  }

  // ----- Custom Refresh header -----
  @Builder
  private RefreshHeader(): void {
    Stack() {
      Column() {
        Canvas(this.canvasContext)
          .width(30)
          .height(30)
          .onReady(() => {
            console.info('[Canvas] Ready')
            this.loadLottieAnimation()
          })
          .onAreaChange(() => {
            console.info('[Canvas] Area changed')
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .height(this.headerMinHeight)
    .backgroundColor(0x89CFF0)
  }

  aboutToAppear(): void {
    console.info('[Lifecycle] Component about to appear')
  }

  aboutToDisappear(): void {
    console.info('[Lifecycle] Component about to disappear')
    this.destroyLottieAnimation()
  }

  // ----- Page -----
  build() {
    Column() {
      Refresh({ refreshing: $$this.isRefreshing, builder: this.RefreshHeader() }) {
        List() {
          ForEach(this.items, (txt: string) => {
            ListItem() {
              Text(txt)
                .width('82%')
                .height(56)
                .fontSize(16)
                .textAlign(TextAlign.Center)
                .borderRadius(16)
                .backgroundColor(0xFFFFFF)
            }
            .margin({ top: 6, bottom: 6 })
          }, (txt: string) => txt)
        }
        .width('100%')
        .height('100%')
        .edgeEffect(EdgeEffect.None)
        .alignListItem(ListItemAlign.Center)
        .scrollBar(BarState.Off)
      }
      .pullToRefresh(true)
      .refreshOffset(this.triggerOffset)
      .backgroundColor(0x89CFF0)
      .onStateChange((status: RefreshStatus) => {
        console.info(`[Refresh] Status changed: ${status}`)

        if (status === RefreshStatus.Refresh) {
          this.startAnimation()
        } else if (status === RefreshStatus.Done || status === RefreshStatus.Inactive) {
          this.stopAnimation()
        }
      })
      .onRefreshing(() => {
        console.info('[Refresh] On refreshing')
        this.startAnimation()

        setTimeout(() => {
          this.isRefreshing = false
        }, 2000)
      })
    }
    .width('100%')
    .height('100%')
  }
}
Enter fullscreen mode Exit fullscreen mode

Notes ensuring “content doesn’t move”

  • The header is drawn in the builder and clipped to a fixed area (constraintSize + clip), while the List retains full height.
  • We avoid translating the list on pull; only the header animates.

Test Results

  • Validated on API Version 19 Release with HarmonyOS 5.1.1 Release SDK in DevEco Studio 5.1.1 Release.
  • Code Check: cleared (per your screenshot).
  • Visual result: top-fixed animation plays during pull/refresh; list items remain stationary.

1.gif

Limitations or Considerations

  • Minimum versions: API 19+, HarmonyOS 5.1.1+; DevEco Studio 5.1.1+.
  • Resource cleanup: Always destroy() the Lottie instance to prevent leaks.
  • Performance: Prefer lightweight Lottie JSONs on low-power devices (watch).
  • Accessibility: Ensure color contrast and non-blocking controls during refresh.

Related Documents or Links

Written by Bunyamin Eymen Alagoz

Top comments (0)