DEV Community

HarmonyOS
HarmonyOS

Posted on

Implementing Smooth Infinite Scroll Using the List Component

Read the original article:Implementing Smooth Infinite Scroll Using the List Component

Requirement Description

When using the List component to implement an infinite scroll calendar scenario, the UI flickers when loading more new data. How can we achieve a smooth scrolling effect?

Background Knowledge

  • The List component contains a series of list items with the same width, suitable for displaying similar data continuously in multiple lines, such as images and text.
  • The onReachStart callback is triggered when the list reaches its starting position. It is triggered once during initialization if the initial index is 0, once when the list scrolls to the starting position, once when the list is swiped past the starting position, and once again when the list bounces back to the starting position.
  • The onScrollIndex callback is triggered when a child component is swiped into or out of the list's display area. When calculating the index value, the ListItemGroup is considered as a whole and occupies one index value; the index values of individual list items within the ListItemGroup are not calculated separately.

Implementation Steps

Scroll the list to the top, add elements to the beginning of the array in the onReachStart method, and scroll to the specified index. The specific implementation is as follows:

Implemented in the State Management V2 scenario: Use the onReachStart callback of the List component. When the list scrolls to the top, the onReachStart callback is triggered. When this event occurs, insert the first element of the array at the beginning of the array and call the scrollToIndex method to scroll the list to the specified index position. Additionally, listen for changes in the scroll index in the onScrollIndex method and update the start variable to achieve an infinite scrolling effect for the List.

// Define a class, marked as observable
// Define an array within the class, marked as trackable
@ObservedV2
class ArrayHolderVTwo {
  @Trace arr: Array<number> = [];

  // constructor
  constructor(count: number) {
    for (let i = 0; i < count; i++) {
      this.arr.push(i);
    }
  }
}

@Entry
@ComponentV2
struct Index {
  @Local arrayHolder: ArrayHolderVTwo = new ArrayHolderVTwo(10);
  @Local totalCount: number = this.arrayHolder.arr.length;
  scroller: Scroller = new Scroller();
  private iCount: number = 1;
  private start: number = 1;

  build() {
    Column({ space: 5 }) {
      List({ space: 20, initialIndex: 0, scroller: this.scroller }) {
        Repeat(this.arrayHolder.arr)
          .virtualScroll({ totalCount: this.totalCount })
          .templateId(() => {
            return 'number';
          })
          .template('number', (r) => {
            ListItem() {
              Column() {
                Row() {
                  Text(r.item.toString());
                  Text(r.item.toString());
                  Text(r.item.toString());
                };

                Row() {
                  Text(r.item.toString());
                  Text(r.item.toString());
                  Text(r.item.toString());
                };

                Row() {
                  Text(r.item.toString());
                  Text(r.item.toString());
                  Text(r.item.toString());
                };
              };
            }
            .margin({ bottom: 5 });
          })
          .each((r) => {
            ListItem() {
              Text(r.index! + ':' + r.item + 'eachMessage');
            };
          });
      }.height('100%')
      .onScrollIndex((start) => {
        this.start = start;
      })
      .onReachStart(() => {
        // The element is the one immediately before the currently displayed element on the screen.
        this.arrayHolder.arr.unshift(this.iCount);
        this.scroller.scrollToIndex(this.start + 1);  // Scroll to the specified index
        this.iCount++;
      });
    }
    .width('100%')
    .margin({ top: 5 })
    .position({
      left: 20
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The specific demonstration is as follows:

image.png

Scenario 2: This is implemented in the state management V1 scenario. The implementation method is similar to that in scenario 1.

The sample code is as follows:

@Observed
class ArrayHolder {
  @Track arr: Array<number> = [];

  // constructor
  constructor(count: number) {
    for (let i = 0; i < count; i++) {
      this.arr.push(i);
    }
  }
}

// BasicDataSource implements the IDataSource interface, which is used to manage listener listening and notify LazyForEach of data updates.
class BasicDataSource implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private originDataArray: string[] = [];

  public totalCount(): number {
    return this.originDataArray.length;
  }

  public getData(index: number): string {
    return this.originDataArray[index];
  }

// This method is called by the framework side to add a listener to the data source of the LazyForEach component.
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      console.info('add listener');
      this.listeners.push(listener);
    }
  }

// This method is called by the framework side to remove the listener from the corresponding LazyForEach component at the data source.
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      console.info('remove listener');
      this.listeners.splice(pos, 1);
    }
  }

  // Notifies LazyForEach to reload all child components.
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    });
  }

// Instruct the LazyForEach component to add a child component at the index specified by index.
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    });
  }

// Notifies the LazyForEach component that the data at the index index has changed and the child component needs to be rebuilt.
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    });
  }

  // Instruct the LazyForEach component to delete the child component at the index specified by index.
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
    });
  }

  // Instructs the LazyForEach component to exchange the child components at the from and to indexes.
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to);
    });
  }

  notifyDatasetChange(operations: DataOperation[]): void {
    this.listeners.forEach(listener => {
      listener.onDatasetChange(operations);
    });
  }
}

class MyDataSource extends BasicDataSource {
  private dataArray: string[] = [];

  public totalCount(): number {
    return this.dataArray.length;
  }

  unshiftData(data: string, index: number): void {
    this.dataArray.unshift(data);
    this.notifyDataAdd(index);
  }

  public getData(index: number): string {
    return this.dataArray[index];
  }

  public pushData(data: string): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  public deleteData(index: number): void {
    if (index >= 0 && index < this.dataArray.length) {
      this.dataArray.splice(index, 1);
      this.notifyDataDelete(index);
    }
  }
}

@Entry
@Component
struct RepeatScrollPage {
  arrayHolder: ArrayHolder = new ArrayHolder(10);
  @State totalCount: number = this.arrayHolder.arr.length;
  @State dataSource: MyDataSource = new MyDataSource();
  scroller: Scroller = new Scroller();
  private iCount: number = 0;
  // fix
  private start: number = 1;
  @State end: number = 1;

  // end
  aboutToAppear(): void {
    // Add numbers 1-10 as the data source.
    for (let i = 1; i <= 10; i++) {
      this.dataSource.pushData(i.toString());
    }
  }

  build() {
    Column({ space: 5 }) {
      List({ space: 20, initialIndex: 0, scroller: this.scroller }) {
        LazyForEach(this.dataSource, (r: string) => {
          ListItem() {
            Column() {
              Row() {
                Text(r);
                Text(r);
                Text(r);
                Text(r);
              };

              Row() {
                Text(r);
                Text(r);
                Text(r);
                Text(r);
              };

              Row() {
                Text(r);
                Text(r);
                Text(r);
                Text(r);
              };
            };
          }
          .margin({ bottom: 5 });
        }, (item: string, index: number) => item + index);
      }.height('100%')
      .onScrollIndex((start, end) => {
        this.start = start;
        this.end = end;
      })
      // end
      .onReachStart(() => {
        // The element is the one immediately before the currently displayed element on the screen.
        this.dataSource.unshiftData(this.iCount.toString(), this.start);
        // fix
        let rect = this.scroller.getItemRect(this.start + 1);// Obtains the size and position of the child component.
        this.scroller.scrollToIndex(this.start + 1); // Scroll to the specified index
        this.scroller.scrollBy(0, -rect.y); // Slide for a specified distance
        // end
        this.totalCount = this.dataSource.totalCount();
        this.iCount++;
      });

    }
    .width('100%')
    .margin({ top: 5 })
    .position({
      left: 20
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The specific demonstration is as follows:

image.png

Code Snippet / Configuration

When the ListItems are of different types, the scroll offset is compared with the height of the ListItems when onScrollStart is triggered, so that more data can be loaded in advance.

@Entry
@Component
struct IndexThree {
  @State arr: Array<string> = [];
  @State page: number = 1;
  pageSize = 10;
  scroller: Scroller = new Scroller();

  aboutToAppear(): void {
    let listData = new ListData();
    let list = listData.getData(this.page, this.pageSize);
    list.forEach(item => {
      this.arr.push(item);
    });
  }

  build() {
    RelativeContainer() {
      List({ scroller: this.scroller }) {
        ListItem() {
          Text('Type1');
        }
        .height(200)
        .width('100%')
        .margin({ bottom: 5 })
        .backgroundColor('#ace');

        ListItem() {
          Text('Type2');
        }
        .height(100)
        .width('100%')
        .margin({ bottom: 5 })
        .backgroundColor('#acF');

        ListItem() {
          WaterFlow() {
            ForEach(this.arr, (item: number, index: number) => {
              FlowItem() {
                Text('Type3: ' + item.toString());
              }
              .height(index % 2 === 0 ? 150 : 200)
              .width('100%')
              .margin({ bottom: 5 })
              .backgroundColor('#ace');
            });
          }
          .columnsGap(7)
          .columnsTemplate('1fr 1fr');
        };
      }
      .onScrollStart(() => {
        if (this.scroller.currentOffset().yOffset > this.scroller.getItemRect(2).height - 1000) {
          this.page++;
          let listData = new ListData();
          let list = listData.getData(this.page, this.pageSize);
          list.forEach(item => {
            this.arr.push(item);
          });
        }
      });
    }
    .height('100%')
    .width('100%');
  }
}

export class ListData {
  data: Array<string> = [];
  totalCount: number = 100;

  constructor() {
    for (let index = 0; index < this.totalCount; index++) {
      this.data.push(index.toString());
    }
  }

  getData(page: number, pageSize: number) {
    let startIndex = (page - 1) * pageSize;
    return this.data.slice(startIndex, startIndex + pageSize);
  }
}
Enter fullscreen mode Exit fullscreen mode

The specific demonstration is as follows:

image.png

Written by Arif Emre Ankara

Top comments (0)