HarmonyOS 7.x: Build iOS-Style Rubber Band Bounce for Lists (System vs Custom Physics)
This article details two approaches to implement edge bounce effects in HarmonyOS 7.x Lists: using the built-in edgeEffect.Spring API for quick integration, and a custom physics-based animation with damping formula and spring curve tuning for deep customization, including parameter recommendations, bottom bounce implementation, performance comparison, and five common pitfalls with solutions.
Introduction
During a 2026 joint debugging session for the Ripple Sleep app, a product manager pulled down the home page past the top edge and saw no visual feedback, assuming the app had frozen. On iOS, the rubber band effect provides immediate edge feedback, but HarmonyOS List defaults to edgeEffect: None, leaving users uncertain whether the list has ended or the app is stuck.
This article presents two solutions: a one-line system configuration and a fully custom physics animation, using the Ripple Sleep pull-to-refresh scenario as a real-world case study.
Why Default List Has No Bounce
HarmonyOS List defaults to edgeEffect: None, meaning no feedback at top or bottom edges. Developers must explicitly configure an edge effect or implement a custom solution.
Solution 1: System edgeEffect (Quick Integration)
Configuration
List() {
LazyForEach(...)
}.edgeEffect(EdgeEffect.Spring) // System bounce effectEdgeEffect Options
None : No feedback, feels rigid.
Spring : Spring bounce, decent feel with system default parameters.
Fade : Fade edge, softer feel.
Pitfall 1
EdgeEffect.Springbounce amplitude is fixed by the system and cannot be customized. For stronger/weaker bounce or custom curves, use Solution 2.
Customizing Bounce Parameters (API 11+)
EdgeEffect.Springdoes not expose physical parameters like springDamping or springResponse. Only two switches are available via EdgeEffectOptions:
List() {
LazyForEach(...)
}.edgeEffect(EdgeEffect.Spring, {
alwaysEnabled: true, // API 11+: enable bounce even when content doesn't fill screen
// effectEdge: EffectEdge.START // API 18+: only bounce at top (optional)
}) alwaysEnabled(API 11+): Whether bounce works when content is smaller than viewport (List default false). effectEdge (API 18+): Which edge(s) get bounce: START, END, or START | END (default both).
Important: EdgeEffectOptions has no springDamping / springResponse physical parameters. The system spring curve's velocity, mass, stiffness, and damping are tuned internally and cannot be adjusted at the app layer. For fully custom bounce feel, you must write your own animation (Solution 2).
Solution 2: Custom Physics Animation (Deep Customization)
Scenario: Ripple Sleep Home Pull-to-Refresh
Requirement: Pull down > 80vp to trigger refresh, with damped rubber-band feel during pull.
import { curves } from '@kit.ArkUI';
@Entry
@Component
struct TravelHome {
@State offsetY: number = 0;
@State isRefreshing: boolean = false;
private scroller: Scroller = new Scroller();
private baseY: number = 0; // accumulated offset at gesture start
private readonly TRIGGER_THRESHOLD = 80; // refresh trigger threshold
private readonly MAX_OFFSET = 150; // damping max offset
build() {
Stack({ alignContent: Alignment.Top }) {
// Pull-to-refresh indicator
if (this.offsetY > 0) {
RefreshIndicator({ offset: this.offsetY, isRefreshing: this.isRefreshing })
.height(this.offsetY)
}
// List
List({ scroller: this.scroller }) {
LazyForEach(this.dataSource, (item: TravelItem) => {
ListItem() { TravelCard({ item: item }) }
}, (item: TravelItem) => item.id.toString())
}
.onScrollIndex((start: number, end: number) => {
// Record scroll position, enable pull-down when at top
})
.parallelGesture(
PanGesture()
.onActionStart((event: GestureEvent) => {
// Record accumulated offset at gesture start to avoid jump from total offset
this.baseY = this.offsetY;
})
.onActionUpdate((event: GestureEvent) => {
// Only effective when list at top and pulling down
if (this.scroller.isAtStart() && event.offsetY > 0) {
// event.offsetY is total gesture offset; add baseY for current accumulated value
const raw = this.baseY + event.offsetY;
// Damping formula: harder to pull the further you go
this.offsetY = this.damping(raw);
}
})
.onActionEnd(() => {
if (this.offsetY > this.TRIGGER_THRESHOLD) {
this.triggerRefresh();
} else {
this.springBack(); // spring back
}
})
)
}
}
// Damping formula (mimics iOS rubber band)
// Physical meaning: larger offset yields smaller per-pixel increment, approaching MAX_OFFSET asymptotically
damping(offset: number): number {
return this.MAX_OFFSET * (1 - Math.exp(-offset / this.MAX_OFFSET));
}
// Spring back animation
// springCurve(velocity, mass, stiffness, damping)
// velocity: initial velocity (0 for static release)
// mass: mass, larger = more sluggish
// stiffness: stiffness, larger = faster rebound
// damping: damping, >1 overdamped no oscillation, <1 underdamped with oscillation
springBack(): void {
animateTo({
duration: 400,
curve: curves.springCurve(0, 1, 200, 12), // slight overdamping, fast rebound without oscillation
}, () => {
this.offsetY = 0;
});
}
async triggerRefresh(): Promise<void> {
this.isRefreshing = true;
await this.refreshData();
this.isRefreshing = false;
this.springBack();
}
}Damping Formula Explained
The damping formula produces an effect where the further you pull, the smaller each pixel increment becomes, simulating a rubber band feel.
Key Parameter Tuning
springCurve stiffness (150-250): Spring stiffness. Higher → faster rebound, lower → slower rebound.
springCurve damping (10-15): Spring damping. >12 overdamped no oscillation, <8 underdamped with oscillation.
springCurve mass (1 fixed): Mass. Larger = more sluggish, generally not adjusted.
springCurve velocity (0 static release): Initial velocity. Inherit gesture velocity at release; 0 means no initial velocity.
MAX_OFFSET (120-180): Max pull offset. Beyond this value pulling feels nearly impossible.
TRIGGER_THRESHOLD (60-100): Refresh trigger threshold. Exceeding this triggers refresh.
Pitfall 2: springCurve 's damping > 15 causes oscillation (multiple rebounds), < 8 feels too soft (rebound too slow). The sweet spot is 12-15 combined with stiffness 150-250.
Bottom Bounce (Pull-Up Load More)
Bottom pull-up for load more also needs bounce feedback. Note: Scroller lacks isAtEnd() method; you must manually determine if scroll position is near the end:
// In TravelHome add member variables
@State bottomOffset: number = 0;
private baseYBottom: number = 0;
// Added method: check if at end
isAtEnd(): boolean {
// currentOffset() returns current scroll offset, getItemRect() returns item rect
const offset = this.scroller.currentOffset();
const lastItem = this.scroller.getItemRect(this.dataSource.totalCount() - 1);
// Current offset + viewport height >= last item top + height means at end
return offset.yOffset >= lastItem.top + lastItem.height - this.scroller.viewportHeight();
} // In List's parallelGesture add PanGesture branch
.parallelGesture(
PanGesture()
.onActionStart(() => {
this.baseYBottom = this.bottomOffset;
})
.onActionUpdate((event: GestureEvent) => {
// Pull up: event.offsetY < 0, negate to get positive "pull-up distance"
if (this.isAtEnd() && event.offsetY < 0) {
this.bottomOffset = this.damping(-this.baseYBottom - event.offsetY);
}
})
.onActionEnd(() => {
if (this.bottomOffset > 60) {
this.loadMore();
} else {
// Spring back, reuse springBack logic
animateTo({ duration: 300, curve: curves.springCurve(0, 1, 200, 12) }, () => {
this.bottomOffset = 0;
});
}
})
)Note: onScrollIndex callback signature is (firstIndex, lastIndex) two parameters (some versions support third centerIndex ), not three parameters start, end, center . Omitting the third parameter does not cause an error.
Performance Comparison
edgeEffect.Spring : 60fps, 0 memory overhead, very low dev cost.
Custom physics animation : 58-60fps, low memory, medium dev cost.
Over-customization (per-frame calc) : 45fps, medium memory, high dev cost.
Pitfall 3: Prefer system capability edgeEffect ; only write custom animation when deep customization (custom damping, linked refresh) is needed. In custom animation, keep onActionUpdate to state updates only, avoid heavy computation.
5 Real-World Pitfalls
1: Using event.offsetY Directly in onActionUpdate Causes Jump After Rebound
Symptom: Pull down halfway, release, after rebound pull again — starting point isn't 0 but previous offset, causing a "jump".
Root Cause: event.offsetY is the total gesture offset (accumulated from finger down), not delta. Each new gesture's event.offsetY starts from 0, but this.offsetY wasn't reset, causing accumulation.
Fix: In onActionStart record gesture-start accumulated offset as baseY; in onActionUpdate use baseY + event.offsetY as current accumulated value (see Solution 2 code).
2: Wrong springCurve Parameters Cause Oscillation or Over-Soft Rebound
Symptom: Writing curves.springCurve(0.5, 0, 0.5, 1) leads to either multiple oscillations or noodle-like slow rebound.
Root Cause: springCurve(velocity, mass, stiffness, damping) expects physical units, not 0-1 normalized coefficients. damping=1 relative to stiffness=0.5 is severely underdamped, guaranteeing oscillation.
Fix: Use physical units. Recommended springCurve(0, 1, 200, 12): velocity=0 (static release), mass=1, stiffness=200, damping=12 (slight overdamping, fast rebound without oscillation). When tuning, fix mass=1 and only adjust stiffness (150-250) and damping (10-15).
3: Scroller Lacks isAtEnd() , Bottom Bounce Detection Fails
Symptom: if (this.scroller.isAtEnd()) compile error or branch never entered.
Root Cause: Scroller only has isAtStart(), no isAtEnd() method . This differs from iOS UIScrollView API and is easy to copy incorrectly.
Fix: Manually compute using currentOffset() + getItemRect() (see Bottom Bounce code). More robust: use onScrollIndex to record endIndex, enable pull-up when endIndex === totalCount() - 1.
4: Duplicate keyGenerator in LazyForEach Causes List Misalignment After Rebound
Symptom: After pull-to-refresh, list item positions don't match pre-refresh, or refresh indicator covers wrong position.
Root Cause: LazyForEach keys must be unique and stable . If item.id.toString() has duplicate IDs, or ID is auto-increment but resets on refresh, keys change and framework reuses components incorrectly, causing UI misalignment.
Fix: Use business unique ID (e.g., travelId), not index or Date.now(). On refresh, preserve old keys ; new items get new keys, deleted items fire delete event — don't replace the entire array.
5: Heavy Computation in onScrollIndex Causes Frame Drops
Symptom: List scrolls at stable 60fps, but enabling onScrollIndex drops to 45-50fps.
Root Cause: onScrollIndex may fire every frame during scroll (not only on stop). Doing console.log, network requests, DB queries, or complex calculations in the callback blocks the main thread, causing frame drops.
Fix: Callback should only do lightweight state updates (e.g., this.endIndex = end). Move heavy computation to onScrollStop (fires once after scroll fully stops) or offload via setTimeout / TaskPool.
Key Takeaways
Prefer edgeEffect.Spring : System capability performs best; custom animation is last resort.
Tune springCurve parameters : damping 12-15 with stiffness 150-250; >15 oscillates, <8 too soft.
onActionUpdate only updates state : No heavy computation, avoids frame drops.
edgeEffect has no physical parameters : EdgeEffectOptions only has alwaysEnabled / effectEdge; for custom feel you must write your own animation.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
