Read the original article:Swiper Child Component Coordinate Jump After Stopping Scroll
Problem Description
When using the Swiper (slider view container), the onAreaChange event in child components returns smoothly changing x-coordinates during finger dragging.
However, after the user releases their finger, the coordinate jumps abruptly, causing visible position discontinuity.
Problem Code Example:
Swiper() {
LazyForEach(this.dataSource, (item: string, index: number) => {
Column() {
Image(item)
.width(this.itemWidth)
.height(this.itemHeight)
.objectFit(ImageFit.Fill)
.borderRadius(5)
}
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.func(index, _oldValue, newValue)
this.getOldValue = _oldValue
this.getNewValue = newValue
})
})
}
.loop(true)
.displayCount(1)
.prevMargin(this.previousWidth)
.nextMargin(this.previousWidth)
.itemSpace(10)
.width('100%')
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.bannerWidth = NumberUtils.lengthToNum(newValue.width)
})
.autoPlay(true)
Background Knowledge
-
onAreaChange: Triggered when a component’s visible area changes, such as position or size. -
Swiper: Provides smooth swiping effects for displaying multiple pages or banners. -
AppUtil.getContext()(from@pura/harmony-utils): Used to obtaincommon.UIAbilityContextfor context-based operations.
Root Cause:
After the user releases the finger, Swiper continues its built-in implicit animation to smoothly complete the transition.
However, implicit animations do not continuously trigger onAreaChange. The event only fires once after the animation ends—when the component’s final position is set—causing a sudden jump in reported coordinates.
Analysis Conclusion
The abrupt coordinate jump occurs because implicit animations do not trigger onAreaChange during motion.
By converting the implicit animation into an explicit animation, continuous coordinate updates can be achieved, allowing smooth position tracking even after the finger is lifted.
Solution
Scenario 1: When the Swiper Data Source Has Three or More Items
Add an empty onContentDidScroll(() => {}) listener outside the Swiper component.
This converts Swiper’s internal animation to an explicit one, ensuring coordinate updates remain smooth after the user lifts their finger.
Modified Example:
import { AppUtil } from '@pura/harmony-utils'
export class NumberUtils {
static lengthToNum(length: Length): number {
if (typeof length === 'number') {
return (length as number)
} else if (typeof length === 'string') {
return parseFloat(length as string)
} else {
let parseRes = length as Resource
return AppUtil.getContext().resourceManager.getNumber(parseRes.id)
}
}
static stringToInt(str: string, defaultValue: number): number {
let par = parseInt(str, 10)
if (isNaN(par)) {
return defaultValue
}
return par
}
static matchAny(num: number, ...nums: number[]): boolean {
for (let index = 0; index < nums.length; index++) {
if (num === nums[index]) {
return true
}
}
return false
}
}
@Entry
@Component
struct Index {
itemWidth: number = 300
itemHeight: number = 163
unselectScale: number = 0.83
itemSpace: number = 10
// 此处'app.media.startIcon'仅作示例
@State imgList: Resource [] = [
$r('app.media.startIcon'),
$r('app.media.startIcon'),
$r('app.media.startIcon')
]
@State dataSource: MyDataSource = new MyDataSource(this.imgList)
@State previousWidth: number = 0
@State @Watch('onBannerWidthChanged') bannerWidth: number = 0
@State viewInfo: Area[] = []
@State xL: number = 0
@State xC: number = 0
@State xR: number = 0
@State mtL: number = 0
@State mtR: number = 0
@State scaleList: number[] = []
@State translateList: number[] = []
@State count: number = 0;
@State getOldValue: Area = {
width: 0,
height: 0,
position: { x: 0, y: 0 },
globalPosition: { x: 0, y: 0 }
}
@State getNewValue: Area = {
width: 0,
height: 0,
position: { x: 0, y: 0 },
globalPosition: { x: 0, y: 0 }
};
aboutToAppear(): void {
for (let i = 0; i < this.imgList.length; i++) {
this.scaleList.push(1)
this.translateList.push(0)
}
}
onBannerWidthChanged() {
if (this.bannerWidth > 0) {
this.previousWidth = (this.bannerWidth - this.itemWidth - this.itemSpace * 2) / 2
this.xC = this.bannerWidth / 2
this.xL = this.xC - this.itemWidth - this.itemSpace
this.xR = (this.xC - this.xL) + this.xC
const leftPredicateLocation =
this.xC - this.itemWidth / 2 - this.itemSpace - (this.itemWidth * this.unselectScale) / 2
this.mtL = leftPredicateLocation - this.xL
const rightPredicateLocation =
this.xC + this.itemWidth / 2 + this.itemSpace + (this.itemWidth * this.unselectScale) / 2
this.mtR = rightPredicateLocation - this.xR
}
}
getScaleByCenterX(x: number): number {
if (this.bannerWidth <= 0 || this.xC <= 0) {
return 1
}
if (x < this.xL || x > this.xR) {
return this.unselectScale
} else if (x >= this.xL && x <= this.xC) {
const a = (1 - this.unselectScale) / (this.xC - this.xL)
const b = this.unselectScale - a * this.xL
return a * x + b
} else if (x >= this.xC && x <= this.xR) {
const a = (this.unselectScale - 1) / (this.xR - this.xC)
const b = 1 - a * this.xC
return a * x + b
} else {
return 1
}
}
getTranslateByCenterX(x: number): number {
if (this.bannerWidth <= 0 || this.xC <= 0) {
return 1
}
if (x < this.xL) {
return this.mtL
} else if (x > this.xR) {
return this.mtR
} else if (x >= this.xL && x <= this.xC) {
const a = (0 - this.mtL) / (this.xC - this.xL)
const b = this.mtL - a * this.xL
return a * x + b
} else if (x >= this.xC && x <= this.xR) {
const a = (this.mtR - 0) / (this.xR - this.xC)
const b = 0 - a * this.xC
return a * x + b
} else {
return 0
}
}
func(index: number, _oldValue: Area, newValue: Area) {
if (index >= this.viewInfo.length) {
this.viewInfo.push(newValue)
} else {
this.viewInfo[index] = newValue
}
console.info('------------------' + this.count++)
const itemCenterX = NumberUtils.lengthToNum(newValue.globalPosition.x ?? 0) + this.itemWidth / 2
const itemScale = this.getScaleByCenterX(itemCenterX)
this.scaleList[index] = itemScale
const itemTranslate = this.getTranslateByCenterX(itemCenterX)
this.translateList[index] = itemTranslate
}
build() {
Column() {
Swiper() {
LazyForEach(this.dataSource, (item: string, index: number) => {
Column() {
Image(item)
.width(this.itemWidth)
.height(this.itemHeight)
.objectFit(ImageFit.Fill)
.borderRadius(5)
}
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.func(index, _oldValue, newValue)
this.getOldValue = _oldValue
this.getNewValue = newValue
})
})
}
.loop(true)
.displayCount(1)
.prevMargin(this.previousWidth)
.nextMargin(this.previousWidth)
.itemSpace(10)
.width('100%')
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.bannerWidth = NumberUtils.lengthToNum(newValue.width)
})
.autoPlay(true)
Column({ space: 10 }) {
Column() {
Text(`Params`)
.fontWeight(FontWeight.Bold)
Text(`bannerWidth: ${this.bannerWidth}`).margin({ top: 8 })
Text(`xL: ${this.xL}, xC: ${this.xC}, xR: ${this.xR}`)
}
.width('100%')
.borderColor(Color.Brown)
.borderWidth(1)
.borderRadius(5)
.padding(5)
.alignItems(HorizontalAlign.Start)
ForEach(this.viewInfo, (item: Area, index: number) => {
Column() {
Text(`Item[${index}]`)
.fontWeight(FontWeight.Bold)
Text(`width: ${NumberUtils.lengthToNum(item.width)}, height: ${NumberUtils.lengthToNum(item.height)}`)
.margin({ top: 8 })
Text(`localX: ${NumberUtils.lengthToNum(item.position.x ??
0)}, localY: ${NumberUtils.lengthToNum(item.position.y ?? 0)}`)
Text(`globalX: ${NumberUtils.lengthToNum(item.globalPosition.x ??
0)}, globalY: ${NumberUtils.lengthToNum(item.globalPosition.y ?? 0)}`)
Text(`scale: ${this.scaleList[index]}`)
}
.width('100%')
.borderColor(Color.Brown)
.borderWidth(1)
.borderRadius(5)
.padding(5)
.alignItems(HorizontalAlign.Start)
})
}.width('100%')
.padding({ left: 15, right: 15 })
.margin({ top: 20 })
}
.height('100%')
.width('100%')
}
}
class MyDataSource implements IDataSource {
private list: Resource[] = []
constructor(list: Resource[]) {
this.list = list
}
totalCount(): number {
return this.list.length
}
getData(index: number): Resource {
return this.list[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
}
unregisterDataChangeListener() {
}
}
Scenario 2: When the Swiper Data Source Has Only Two Items
In looping scenarios (loop(true)), when both ends of the Swiper display the same page (due to prevMargin and nextMargin), the onContentDidScroll callback does not trigger.
To fix this, disable the looping behavior by setting .loop(false) to ensure smooth coordinate changes.
Modified Example:
Swiper() {
LazyForEach(this.dataSource, (item: string, index: number) => {
Column() {
Image(item)
.width(this.itemWidth)
.height(this.itemHeight)
.objectFit(ImageFit.Fill)
.borderRadius(5)
}
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.func(index, _oldValue, newValue)
this.getOldValue = _oldValue
this.getNewValue = newValue
})
})
}
.onContentDidScroll(() => {
})
.loop(false)
.displayCount(1)
.prevMargin(this.previousWidth)
.nextMargin(this.previousWidth)
.itemSpace(10)
.width('100%')
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.bannerWidth = NumberUtils.lengthToNum(newValue.width)
})
.autoPlay(true)
Verification Result
-
Before fix:
-
onAreaChangecoordinates jumped abruptly after lifting the finger. - Visual discontinuity during Swiper motion.
-
-
After fix (≥3 data items):
- Smooth coordinate transition; no sudden jumps.
-
After fix (2 data items,
.loop(false)):- Continuous, stable coordinate changes with seamless motion.
- Supported from API Version 19 Release and above.
- Requires HarmonyOS 5.1.1 Release SDK or later.
- Must be compiled and executed using DevEco Studio 5.1.1 Release or later.

Top comments (0)