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
-
Refresh: ArkUI container enabling pull-down refresh with default or custom header via
builder. It supports explicit animations (e.g.,animateTo) for fine-grained control.
Docs: Refresh component,animateToexplicit animation. -
Lottie: OpenHarmony-adapted animation library that plays JSON vector animations, supporting play/pause/loop and canvas rendering.
- Lottie (Quick App component reference): https://developer.huawei.com/consumer/en/doc/quickApp-References/quickapp-component-lottie-0000001266273702
Implementation Steps
1.Install Lottie package
ohpm install @ohos/lottie
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
Canvasat the top inside the Refreshbuilder. - 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
-
onStateChange:-
Pulling→ ensure animation is loaded (loadPullDownAnimation()). -
Refreshing→play()the animation. -
Idle/End→destroy()the animation instance to release memory.
-
-
onRefreshing:- Do your async work, then set
isRefreshing = false.
- Do your async work, then set
(Optional)
onOffsetChangeto map pull distance to animation progress for more interactivity:
5.Keep content from shifting
- Use the Refresh
builderas a fixed top overlay (viaStack+clip(true)), and do not animate the List’s position/size. - Maintain
Listheight at100%so only the header animates while content remains visually stationary.
6.Tune thresholds
-
pullToRefresh(true)andrefreshOffset(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%')
}
}
Notes ensuring “content doesn’t move”
- The header is drawn in the
builderand clipped to a fixed area (constraintSize+clip), while theListretains 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.
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
- Refresh
- animateTo
- Lottie sample JSONs
onOffsetChange

Top comments (0)