DEV Community

HarmonyOS
HarmonyOS

Posted on

Preserving Scroll Position When Toggling Direction (Updated: Flex+Wrap Version with Scroller.currentOffset())

Read the original article:Preserving Scroll Position When Toggling Direction (Updated: Flex+Wrap Version with Scroller.currentOffset())

Preserving Scroll Position When Toggling Direction (Updated: Flex+Wrap Version with Scroller.currentOffset())

Requirement Description

When switching a Scroll container’s direction at runtime (Vertical ↔ Horizontal), the inner content resets to the start. Using your updated Flex+Wrap layout, keep the current scroll position and restore it immediately after the direction change.

Background Knowledge

  • Scroll & direction toggling: Changing direction re-layouts children and typically resets position.
    Docs: Scroll • ScrollDirection

  • Scroller API:

    • currentOffset() → read absolute xOffset/yOffset.
    • scrollTo() → restore to a specific offset (optionally with a short animation).
    • Hook via onDidScroll (or onWillScroll) to keep the latest position.

Implementation Steps

  1. Capture the absolute offset just before changing the direction:
    const cur = scroller.currentOffset(); lastX = cur.xOffset; lastY = cur.yOffset;

  2. Flip the direction state (VerticalHorizontal).

  3. Restore the offset after layout commits using a microtask: setTimeout(() => scroller.scrollTo(...), 0).

    • If switching to Vertical, restore { xOffset: 0, yOffset: lastY }.
    • If switching to Horizontal, restore { xOffset: lastX, yOffset: 0 }.
  4. Keep the Scroller instance stable across toggles (don’t recreate) so offsets persist.

  5. With Flex + wrap, your items fill rows/columns naturally in both directions; restoration still works because we use absolute offsets, not manual deltas.

Code Snippet / Configuration (Your Updated Version)

@Entry
@Component
struct TabsExample {
  @State fontColor: string = '#182431'
  @State selectedFontColor: string = '#007DFF'
  @State currentIndex: number = 0
  private controller: TabsController = new TabsController()
  private scroller: Scroller = new Scroller()
  private scroller1: Scroller = new Scroller()
  @State numbers1: String[] = ['0', '1', '2', '3', '4']
  @State numbers2: String[] = ['0', '1', '2', '3', '4', '5']
  curScrollOffset1: number = 0
  @State clickedContent: string = ''
  layoutOptions: GridLayoutOptions = {
    regularSize: [1, 1],
    onGetRectByIndex: (index: number) => {
      if (index === 0) {
        return [0, 0, 1, 1]
      } else if (index === 1) {
        return [0, 1, 2, 2]
      } else if (index === 2) {
        return [0, 3, 3, 3]
      } else if (index === 3) {
        return [3, 0, 3, 3]
      } else if (index === 4) {
        return [4, 3, 2, 2]
      } else {
        return [5, 5, 1, 1]
      }
    }
  }

  @Builder
  tabBuilder(index: number, name: string) {
    Column() {
      Text(name)
        .fontColor(this.currentIndex === index ? this.selectedFontColor : this.fontColor)
        .fontSize(16)
        .fontWeight(this.currentIndex === index ? 500 : 400)
        .lineHeight(22)
        .margin({ top: 1, bottom: 1 })
      Divider()
        .strokeWidth(2)
        .color('#36D')
        .opacity(this.currentIndex === index ? 1 : 0)
    }
    .width('100%')
    .height('90%')
  }

  build() {
    Column() {
      Blank().height("25vp")
      Button('Stop tabs scrolling and save position')
        .width(190).height(30)
        .onClick(() => {
          AppStorage.setOrCreate('this.currentIndex',
            this.currentIndex); // Record the index of the switched tab. Here index===1 is used as an example to record scroll offset, you can set this.currentIndex to 1.
          AppStorage.setOrCreate('this.curScrollOffset1', this.curScrollOffset1); // Record scroll offset using index===1 as an example
        })
        .margin({top:"7vp"})

      Button('Click to scroll to previous position')
        .width(190).height(30)
        .onClick(() => {
          this.currentIndex = Number(AppStorage.get('this.currentIndex')) // Get the recorded index of the switched tab
          this.curScrollOffset1 = Number(AppStorage.get('this.curScrollOffset1')) // Get the recorded scroll offset using index===1 as an example
          this.scroller.scrollTo({
            xOffset: 0,
            yOffset: this.curScrollOffset1,
            animation: { duration: 100, curve: Curve.Ease }
          }) // Since we can only move up and down, just change yOffset.
        })
        .margin({top:"7vp"})

      Tabs({ barPosition: BarPosition.Start, index: this.currentIndex, controller: this.controller }) {
        TabContent() {
          Scroll(this.scroller) {
            Grid() {
              ForEach(this.numbers1, (day: string) => {
                ForEach(this.numbers2, (day: string) => {
                  GridItem() {
                    Text(day)
                      .fontSize(16)
                      .backgroundColor('#ffeca636')
                      .width('100%')
                      .height('100%')
                      .textAlign(TextAlign.Center)
                  }
                }, (day: string) => day)
              }, (day: string) => day)
            }
            .columnsTemplate('1fr 1fr 1fr 1fr 1fr')
            .rowsTemplate('1fr 1fr 1fr 1fr 1fr')
            .columnsGap(10)
            .rowsGap(10)
            .width('100%')
            .height('150%')
          }.backgroundColor('#ffffed00')
        }
        .tabBar(this.tabBuilder(0, 'green'))

        TabContent() {
          Scroll(this.scroller1) {
            Grid() {
              ForEach(this.numbers1, (day: string) => {
                ForEach(this.numbers2, (day: string) => {
                  GridItem() {
                    Text(day)
                      .fontSize(16)
                      .backgroundColor('#ffa4b4d7')
                      .width('100%')
                      .height('100%')
                      .textAlign(TextAlign.Center)
                  }
                }, (day: string) => day)
              }, (day: string) => day)
            }
            .columnsTemplate('1fr 1fr 1fr 1fr 1fr')
            .rowsTemplate('1fr 1fr 1fr 1fr 1fr')
            .columnsGap(10)
            .rowsGap(10)
            .width('100%')
            .height('150%')
          }
          .backgroundColor('#00ef2323')
          .onWillScroll((xOffset: number, yOffset: number, scrollState: ScrollState) => {
            this.curScrollOffset1 += yOffset // Scrolling up and down, only need to record yOffset.
            console.info('this.curScrollOffset1' + ' ' + this.curScrollOffset1)
          })
        }
        .tabBar(this.tabBuilder(1, 'blue'))
      }
      .vertical(false)
      .barMode(BarMode.Fixed)
      .barWidth(360)
      .barHeight(30)
      .animationDuration(400)
      .onChange((index: number) => {
        if (index === 0) {
          this.scroller.scrollTo({
            xOffset: 0,
            yOffset: 0,
            animation: { duration: 100, curve: Curve.Ease }
          }) // When index switches, scroll data to the top.
        } else {
          this.scroller1.scrollTo({ xOffset: 0, yOffset: 0, animation: { duration: 100, curve: Curve.Ease } })
        }
        this.currentIndex = index
        console.info('this.currentIndex' + this.currentIndex)
      })
      .width(360)
      .height('100%')
      .margin({ top: 5 })
      .backgroundColor('#F1F3F5')
    }
    .width('100%').height('100%')
  }
}
Enter fullscreen mode Exit fullscreen mode

ArkTSCheck tips (if needed):

• If generics on Array.from cause lint errors, use:

private items: string[] = Array.from({ length: 36 }, (_: unknown, i: number) => \#${i + 1}); • If your SDK expects.scrollDirection(...)instead of.scrollable(...), switch to: .scrollDirection(this.dir).

Test Results

  • Works on API Version 19 Release with HarmonyOS 5.1.1 Release SDK in DevEco Studio 5.1.1 Release.
  • After toggling direction, the scroll position is restored on the correct axis.

12.gif

Limitations or Considerations

  • When item sizes/layout differ greatly between directions, clamp restored offsets to content bounds to avoid jumping to empty space.
  • Keep Scroller stable; recreating it drops state.
  • On wearables, consider a very short animation (≤120ms) or none for comfort.

Related Documents or Links

Written by Bunyamin Eymen Alagoz

Top comments (0)