DEV Community

HarmonyOS
HarmonyOS

Posted on

Swiper Child Component Coordinate Jump After Stopping Scroll

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)
Enter fullscreen mode Exit fullscreen mode

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 obtain common.UIAbilityContext for 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() {
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Verification Result

  • Before fix:
    • onAreaChange coordinates 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.

Code Check Cleared:
cke_3176.png

Written by Arif Emre Ankara

Top comments (0)