DEV Community

zhonghua
zhonghua

Posted on • Edited on

Practical Development of HarmonyOS NEXT: Implementing Efficient Pull-Down Refresh and Pull-Up Load Components (Part II)

Practical Development of HarmonyOS NEXT: Implementing Efficient Pull-Down Refresh and Pull-Up Load Components (Part II) Core Logic of Refresh and Integration with Empty Pages

Foreword:
In the previous article, we delved into how to implement a fully functional empty page component in HarmonyOS. Now, we will move on to the core logic implementation of pull-down refresh and pull-up load features. This is not just about technical implementation, but more importantly, it is about a deep understanding of user experience. This article will detail how to integrate empty pages with the logic of pull-down refresh and pull-up load to create an efficient and user-friendly interactive experience.

I. Building the Core Logic
When developing pull-down refresh and pull-up load features, we first need to define several key fields: the total number of pages, the starting page number, and the number of data entries per page. These fields are the foundation for pagination requests and the starting point for our logic implementation.

So in our core utility class, we need to make the network request into a function parameter for external requests.
Code Example:

private requestData:(currentPage:number,pageSize:number)=>void
Enter fullscreen mode Exit fullscreen mode

Next, we need to implement external call listeners so that our loading logic can communicate with the outside. This includes refresh completion, load completion, empty data listening, etc.

Code Example:

export interface PullRefreshListener<T> {
  refreshCompleted:()=>void; 
  loadMoreCompleted:()=>void;
  emptyPage:()=>void;
  setData:(data:T[], isRefreshLast:boolean)=>void;
  lastData:()=>void;
  moreLoadFail:(error:BaseError)=>void;
  onLoadFail:(error:BaseError)=>void;
}
Enter fullscreen mode Exit fullscreen mode

II. Implementation of Pull-Down Refresh and Pull-Up Load
When implementing pull-down refresh and pull-up load, we need to consider various states, including empty data and load errors. The core logic includes determining whether the data is empty, whether the last page has been reached, and how to handle load errors.

Core Logic Code:

import { BaseError } from '@kangraoo/baselibrary/src/main/ets/exception/NetworkError';
import { Log } from '@kangraoo/utils';

export interface PullRefreshListener<T> {
  refreshCompleted:()=>void;
  loadMoreCompleted:()=>void;
  emptyPage:()=>void;
  setData:(data:T[], isRefreshLast:boolean)=>void;
  lastData:()=>void;
  moreLoadFail:(error:BaseError)=>void;
  onLoadFail:(error:BaseError)=>void;
}

export class PullRefreshList<T>{

  // Total number of pages
  readonly PAGE_COUNT_SIZE:number = 10
  // Current page number
  readonly CURRENT_PAGE:number = 1

  private isRefreshLast:boolean = true
  // Start
  private currentPage:number
  // Page number
  private pageSize: number

  // Network request data
  private requestData:(currentPage:number,pageSize:number)=>void

  private pullRefreshListener:PullRefreshListener<T>;

  constructor(requestData: (currentPage: number, pageSize: number) => void, pullRefreshListener: PullRefreshListener<T>
    ,currentPage?: number, pageSize?: number) {
    this.currentPage = currentPage??this.CURRENT_PAGE;
    this.pageSize = pageSize??this.PAGE_COUNT_SIZE;
    this.requestData = requestData;
    this.pullRefreshListener = pullRefreshListener;
  }

  private makeCurrentPage(){
    this.currentPage++;
    Log.debug(`Current ${this.currentPage}`);
    this.isRefreshLast = false;
  }

  // Refresh
  refreshData() {
    this.isRefreshLast = true;
    this.currentPage = 1;
    this.requestData(this.currentPage,this.pageSize);
  }

  /// Refresh the already loaded data (only when data exists)
  refreshLoadData(){
    this.isRefreshLast = true;
    Log.debug(`Current${this.currentPage} Total ${this.pageSize}*${this.currentPage}`);
    this.requestData(1,this.pageSize*(this.currentPage--));
  }

  /// Load more
  loadMore() {
    this.isRefreshLast = false;
    this.requestData(this.currentPage,this.pageSize);
  }


  dataError(error:BaseError) {
    this.pullRefreshListener.loadMoreCompleted();
    this.pullRefreshListener.refreshCompleted();
    if (this.isRefreshLast) {
      this.pullRefreshListener.onLoadFail(error);
    } else {
      this.pullRefreshListener.moreLoadFail(error);
    }
  }



  dataSucces(data:T[]|null, total:number) {
    this.pullRefreshListener.loadMoreCompleted();
    this.pullRefreshListener.refreshCompleted();
    if (total === 0) {
      if (this.isRefreshLast) {
        this.pullRefreshListener.setData([], this.isRefreshLast);
        this.pullRefreshListener.emptyPage();
      }
    } else {
      if (data === null || data.length===0) {
        if (this.isRefreshLast) {
          this.pullRefreshListener.setData([], this.isRefreshLast);
          this.pullRefreshListener.emptyPage();
        }
      } else {
        Log.debug(`page${this.currentPage},total${total}`);
        this.pullRefreshListener.setData(data, this.isRefreshLast);
        if (this.pageSize * this.currentPage >= total) {
          this.pullRefreshListener.lastData();
        }
        this.makeCurrentPage();
      }
    }
  }


}
Enter fullscreen mode Exit fullscreen mode

III. Component Selection and Basic Logic
Choosing the right components is crucial for implementing pull-down refresh and pull-up load. We have chosen the system component Refresh, which provides natural pull-down refresh handling and page customization features.

To achieve this, we need to integrate the empty page from the previous article with the Refresh component.

First, let's familiarize ourselves with a few variables:

  • Empty page state: layoutType
  • Pull-up refresh completion: finished
  • Pull-up loading: loading
  • Pull-down refresh: isRefreshing
  • Refresh status: refreshStatus

Next, let's get to know a few methods:

  • The content wrapped by pull-down refresh, usually a list or other list-like component: content
  • The method called on pull-down: onRefreshing
  • The method called when the refresh button on the empty page is clicked: onButtonRefreshing

Core Code:

@Preview
@Component
export struct PullRefreshWidget {

  public mCmpController: PullRefreshController|null = null;

  aboutToAppear(): void {
    if (this.mCmpController!=null) {
      this.mCmpController.attach(this); // Bind the controller
    }
  }

  @State isRefreshing: boolean = false
  @State
  refreshStatus: RefreshStatus = RefreshStatus.Inactive
  @Link finished: boolean

  @Link loading: boolean

  @Link moreLoadFail: boolean

  @BuilderParam
  content:()=>void

  onRefreshing?:()=>void

  onButtonRefreshing?:()=>void



  @Builder
  baseRefresh(){
    Refresh({
      refreshing : $$this.isRefreshing,
      builder: this.customRefreshComponent()
    }){
      this.content()
    }.onRefreshing(()=>{
      if(this.onRefreshing){
        this.onRefreshing()
      }
    })
    .onStateChange(async (status) => {
      this.refreshStatus = status
    })
    .height("100%")
  }

  @State
  layoutType : EmptyStatus =  EmptyStatus.none

  build() {
    EmptyWidget({
      child : ()=>{
        this.baseRefresh()
      },
      layoutType : this.layoutType,
      refresh : ()=>{
        if(this.onButtonRefreshing){
          this.onButtonRefreshing()
        }
      }
    })

  }

  @Builder
  customRefreshComponent()
  {
    Stack()
    {
      Row()
      {
        LoadingProgress().height(32)
        Text(this.getTextByStatus()).fontSize(16).margin({left:20})
      }
      .alignItems(VerticalAlign.Center)
    }
    .align(Alignment.Center)
    .clip(true)
    .constraintSize({minHeight:32}) // Set minimum height constraint to ensure the custom component's height does not fall below minHeight when the refresh area's height changes
    .width("100%")
  }

  getTextByStatus() {
    switch (this.refreshStatus) {
      case RefreshStatus.Drag:
        return Application.getInstance().resourceManager.getStringSync($r("app.string.continue_pull_down").id)
      case RefreshStatus.OverDrag:
        return Application.getInstance().resourceManager.getStringSync($r("app.string.release_to_load").id)
      case RefreshStatus.Refresh:
        return Application.getInstance().resourceManager.getStringSync($r("app.string.loading").id)
    }
    return ""
  }

}

export class PullRefreshController{

  private mComponent: PullRefreshWidget | null = null;

  attach(component: PullRefreshWidget) {
    this.mComponent = component;
  }
  refreshCompleted(){
    if(this.mComponent!=null){
      this.mComponent.isRefreshing = false;
    }
  }
  loadMoreCompleted() {
    if(this.mComponent!=null){
      this.mComponent.finished = false
      this.mComponent.moreLoadFail = false
      this.mComponent.loading = false
    }
  }
  lastData(){
    if(this.mComponent!=null){
      this.mComponent.finished = true
    }
  }
  moreLoadFail(){
    if(this.mComponent!=null){
      this.mComponent.moreLoadFail = true;
    }
  }
  emptyPage(){
    if(this.mComponent!=null){
      this.mComponent.layoutType = EmptyStatus.nodata
    }
  }
  nonePage(){
    if(this.mComponent!=null){
      this.mComponent.layoutType = EmptyStatus.none
    }
  }
  onLoadFail(){
    if(this.mComponent!=null){
      this.mComponent.layoutType = EmptyStatus.fail
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

IV. Data Source Selection and Implementation
In business terms, to use a waterfall flow, I adopted WaterFlow as the data component. At the same time, we implemented a data source class to support dynamic data loading and updating.

First, since I used LazyForEach for the data source, I need to create a wrapper class.

BasicDataSource implements IDataSource to handle data listeners.

// Basic implementation of IDataSource to handle data listener
export class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];

  private originDataArray: T[] = [];

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

  // Register a data change listener
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener)
    }
  }

  // Unregister a data change listener
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener)
    if (pos >= 0) {
      this.listeners.splice(pos, 1)
    }
  }

  // Notify the listener of data reload
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded()
    })
  }

  // Notify the listener of data addition
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index)
    })
  }

  // Notify the listener of data change
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index)
    })
  }

  // Notify the listener of data deletion
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index)
    })
  }

  // Notify the listener of data movement
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to)
    })
  }

  // Add a data entry at the specified position
  public addData(index: number, data: T): void {
    this.originDataArray.splice(index, 0, data);
    this.notifyDataAdd(index);
  }

  // Add a data entry at the first position
  public add1stItem(data: T): void {
    this.addData(0,data)
  }

  // Add a data entry
  public pushData(data: T): void {
    this.originDataArray.push(data);
    this.notifyDataAdd(this.originDataArray.length - 1);
  }

  // Delete a data entry at the specified index
  public deleteItem(index: number): void {
    this.originDataArray.splice(index, 1)
    this.notifyDataDelete(index)
  }

  // Delete the first data entry
  public delete1stItem(): void {
    this.deleteItem(0)
  }

  // Delete the last data entry
  public deleteLastItem(): void {
    this.originDataArray.splice(-1, 1)
    this.notifyDataDelete(this.originDataArray.length)
  }

  // Clear data
  public clearData () {
    this.originDataArray = []
    this.notifyDataReload()
  }

  // Set new data
  public setData(dataArray: T[]){
    this.originDataArray = dataArray
    this.notifyDataReload()
  }

  // Add list data
  public addDatas(dataArray: T[]){
    let l = this.originDataArray.length
    this.originDataArray.push(...dataArray)
    this.notifyDataAdd(l-1)
    // this.originDataArray.push(...dataArray)
    // this.notifyDataReload()
  }

}
Enter fullscreen mode Exit fullscreen mode

My data class is ExperienceListResponse, and I need to implement this data source.

class WaterFlowDataSource extends BasicDataSource<ExperienceListResponse> {

}
Enter fullscreen mode Exit fullscreen mode

V. Complete Pull-Down Refresh Implementation
Finally, we integrate all components and logic to implement a complete pull-down refresh feature. This includes data loading, state updates, and handling user interactions.

Complete Code:

@Component
export struct MyPullRefreshWidget{

  @State list:ExperienceListResponse[] = []

  dataSource: WaterFlowDataSource = new WaterFlowDataSource()

  mCmpController: PullRefreshController = new PullRefreshController()

  pullRefreshList :PullRefreshList<ExperienceListResponse> = new PullRefreshList((currentPage,pageSize)=>{
    setTimeout(()=>{
      QuickResponsitory.getInstance().experienceList(currentPage,pageSize).then(value=>{
        LibLoading.hide();
        if (value instanceof SuccessData) {
          let data = value as (SuccessData<ApiResult<ExperienceListResponse[]>>)
          this.list = data.data?.data ?? []
          this.pullRefreshList.dataSucces(this.list,data.data?.page?.totalCount??0)
        } else if (value instanceof ErrorData) {
          this.pullRefreshList.dataError(value.error)
        }
      })
    },1000)

  },{
    refreshCompleted:()=>{
      this.mCmpController.refreshCompleted()
    },
    loadMoreCompleted:()=> {
      this.mCmpController.loadMoreCompleted()
    },
    emptyPage:()=> {
      this.mCmpController.emptyPage()
    },
    setData:(data:ExperienceListResponse[], isRefreshLast:boolean)=>{
      if(isRefreshLast){
        this.mCmpController.nonePage()
        this.dataSource.setData(data)
      }else{
        this.dataSource.addDatas(data)
      }
    },
    lastData:()=> {
      this.mCmpController.lastData()
    },
    moreLoadFail:(error:BaseError)=>{
      this.mCmpController.moreLoadFail()
    },
    onLoadFail:(error:BaseError)=>{
      this.mCmpController.onLoadFail()
    }
  })

  aboutToAppear(): void {
    LibLoading.show();
    this.pullRefreshList.refreshData()
  }

  @State finished: boolean = false // Whether the loading is complete

  @State loading: boolean = false

  @State moreLoadFail: boolean = false

  @Builder
  itemFoot() {
    if (this.finished) {
      Row() {
        Text($r("app.string.no_more_data"))
          .fontSize(12)
      }
      .width("100%")
      .height(40)
      .justifyContent(FlexAlign.Center)
    } else {
      if (this.loading) {
        // Loading in progress
        Row({ space: 10 }) {
          Text($r("app.string.loading_data"))
            .fontSize(12)
          LoadingProgress()
            .width(20)
            .height(20)
        }
        .width("100%")
        .height(40)
        .justifyContent(FlexAlign.Center)
      }else {
        if(this.moreLoadFail){
          Row() {
            Text($r("app.string.data_loading_failed"))
              .fontSize(12)
          }
          .width("100%")
          .height(40)
          .justifyContent(FlexAlign.Center)
        }
      }
    }
  }

  @Builder
  dataContent(){
    WaterFlow({footer:this.itemFoot()}){
      LazyForEach(this.dataSource,(item:ExperienceListResponse,index:number)=>{
        FlowItem(){
          ExperienceListItem({experience:item}).padding(4)
        }
      },(item:ExperienceListResponse,index:number)=>{
        return item.id
      })
    }
    .layoutDirection(FlexDirection.Column)
    .columnsTemplate("1fr 1fr")
    .onReachEnd(()=>{
      // Valve control
      if (!this.loading && !this.finished) {
        this.loading = true
        this.pullRefreshList.loadMore()
      }
    })
  }

  build() {
    PullRefreshWidget({
      mCmpController:this.mCmpController,
      content:()=>{
        this.dataContent()
      },
      onRefreshing:()=>{
        this.pullRefreshList.refreshData()
      },
      onButtonRefreshing:()=>{
        LibLoading.show();
        this.pullRefreshList.refreshData()
      }
    ,finished:this.finished,loading:this.loading,moreLoadFail:this.moreLoadFail})
  }
}
Enter fullscreen mode Exit fullscreen mode

V. In-Depth Analysis and Experience Sharing

During the implementation of pull-down refresh and pull-up load, I encountered some challenges, such as how to ensure the smoothness of data loading, how to handle network request exceptions, and how to effectively integrate with empty pages. Through continuous testing and optimization, we found some solutions that make the entire component not only powerful but also provide a good user experience.

Summary:
Through this article, we have not only learned how to implement the core logic of pull-down refresh and pull-up load in HarmonyOS, but also understood how to integrate these logics with the empty page component to provide a richer and smoother user experience.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.