💬

HarmonyOS NEXT開発実践:効率的なプルダウンリフレッシュとプルアップロードコンポーネントの実装(二)

に公開

序文:

前回の記事では、HarmonyOSで機能が完全な空ページコンポーネントを実現する方法を深く探求しました。今回からは、プルダウンリフレッシュとプルアップロード機能の核心ロジックの実装に移ります。これは単なる技術実現ではなく、ユーザー体験を深く理解することでもあります。この記事では、空ページとプルダウンリフレッシュ、プルアップロードロジックを組み合わせ、効率的でユーザーにやさしいインタラクティブな体験を提供する方法を詳しく紹介します。

一、核心ロジックの構築

プルダウンリフレッシュとプルアップロード機能を開発する際、最初にいくつかの重要なフィールドを定義する必要があります:ページ総数、開始ページ数、ページデータ件数。これらのフィールドは、ページ分割リクエストの基礎であり、私たちがロジックを実現する出発点でもあります。

それでは、私たちの核心ツールクラスでは、ネットワークリクエストを関数のパラメーターとして行い、外部リクエストを行う必要があります。
コード例:

private requestData:(currentPage:number,pageSize:number)=>void

次に、外部呼び出しリスナーを実装する必要があります。これにより、私たちのロードロジックが外部とのコミュニケーションを行うことができます。これは、リフレッシュ完了、ロード完了、データが空であるリスナーなどです。

コード例:

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;
}

二、プルダウンリフレッシュとプルアップロードの実装

プルダウンリフレッシュとプルアップロードを実装する際、データが空である、ロードエラーなど、さまざまな状態を考慮する必要があります。核心ロジックには、データが空であるかどうか、最後のページに達したかどうか、ロードエラーをどのように処理するかが含まれます。

核心ロジックコード:

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>{

  // ページ総数
  readonly PAGE_COUNT_SIZE:number = 10
  // 現在のページ番号
  readonly CURRENT_PAGE:number = 1

  private isRefreshLast:boolean = true
  // 開始
  private currentPage:number
  // ページ数
  private pageSize: number

  // ネットワークリクエストデータなど
  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(`現在 ${this.currentPage}`);
    this.isRefreshLast = false;
  }

  // リフレッシュ
  refreshData() {
    this.isRefreshLast = true;
    this.currentPage = 1;
    this.requestData(this.currentPage,this.pageSize);
  }

  /// 既にロードされたデータのリフレッシュ(データが存在する場合のみリフレッシュ可能)
  refreshLoadData(){
    this.isRefreshLast = true;
    Log.debug(`現在${this.currentPage} 合計 ${this.pageSize}*${this.currentPage}`);
    this.requestData(1,this.pageSize*(this.currentPage--));
  }

  /// ロード
  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();
      }
    }
  }


}

三、コントロールの選択と基本ロジック

プルダウンリフレッシュとプルアップロードを実現するには、適切なコントロールを選択することが重要です。システムコントロールのRefreshを選択しました。これは、天然のプルダウンリフレッシュ処理とページのカスタマイズ機能を提供します。

このためには、前回の記事の空ページとrefreshコントロールを組み合わせる必要があります。

まず、いくつかの変数に慣れる必要があります。

空ページの状態 layoutType
プルアップロードが終了した finished
プルアップロード中 loading
プルダウンリフレッシュ isRefreshing
リフレッシュステータス refreshStatus

次に、いくつかのメソッドに慣れます。
プルダウンリフレッシュでラップする内容は、通常listまたはその他のリストです content
プルダウンで呼び出すメソッド onRefreshing
空ページ上のリフレッシュボタンをクリックする onButtonRefreshing

核心コード:

@Preview
@Component
export struct PullRefreshWidget {

  public mCmpController: PullRefreshController|null = null;

  aboutToAppear(): void {
    if (this.mCmpController!=null) {
      this.mCmpController.attach(this); // コントローラーをバインドする
    }
  }

  @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}) // 最小高制約を設定して、リフレッシュエリアの高さが変化してもカスタムコンポーネントの高さがminHeight以下にはならないようにする
    .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
    }
  }

}

四、データソースの選択と実装

ビジネス上、瀑布流を使用するために、WaterFlowを使用してデータコンポーネントとしました。また、データの動的ロードと更新をサポートするデータソースクラスを実装しました。

まず、LazyForEachを使用するデータソースについては、ラッパークラスを作成する必要があります。

BasicDataSourceは、IDataSourceを実装していくつかのメソッドを書きます

// IDataSourceの基本実装でデータリスナーを処理する
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];
  }

  // データ変更コントローラーを登録する
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener)
    }
  }

  // データ変更コントローラーを登録解除する
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener)
    if (pos >= 0) {
      this.listeners.splice(pos, 1)
    }
  }

  // コントローラーにデータを再ロードする通知を送信する
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded()
    })
  }

  // コントローラーにデータが追加された通知を送信する
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index)
    })
  }

  // コントローラーにデータが変更された通知を送信する
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index)
    })
  }

  // コントローラーにデータが削除された通知を送信する
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index)
    })
  }

  // コントローラーにデータの位置が変更された通知を送信する
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to)
    })
  }

  // 指定された位置にデータを追加する
  public addData(index: number, data: T): void {
    this.originDataArray.splice(index, 0, data);
    this.notifyDataAdd(index);
  }

  // 最初の位置にデータを追加する
  public add1stItem(data: T): void {
    this.addData(0,data)
  }

  // データを追加する
  public pushData(data: T): void {
    this.originDataArray.push(data);
    this.notifyDataAdd(this.originDataArray.length - 1);
  }

  // 指定されたインデックス位置の要素を削除する
  public deleteItem(index: number): void {
    this.originDataArray.splice(index, 1)
    this.notifyDataDelete(index)
  }

  // 最初の要素を削除する
  public delete1stItem(): void {
    this.deleteItem(0)
  }

  // 最後の要素を削除する
  public deleteLastItem(): void {
    this.originDataArray.splice(-1, 1)
    this.notifyDataDelete(this.originDataArray.length)
  }

  // データをクリアする
  public clearData () {
    this.originDataArray = []
    this.notifyDataReload()
  }

  // 新しいデータを設定する
  public setData(dataArray: T[]){
    this.originDataArray = dataArray
    this.notifyDataReload()
  }

  // リストデータを追加する
  public addDatas(dataArray: T[]){
    let l = this.originDataArray.length
    this.originDataArray.push(...dataArray)
    this.notifyDataAdd(l-1)
    // this.originDataArray.push(...dataArray)
    // this.notifyDataReload()
  }

}

私のデータクラスはExperienceListResponseで、このデータソースを実装する必要があります

class WaterFlowDataSource extends BasicDataSource<ExperienceListResponse> {

}

五、完全なプルダウンリフレッシュの実装

最後に、すべてのコンポーネントとロジックを統合して、完全なプルダウンリフレッシュ機能を実現します。これは、データのロード、ステータスの更新、ユーザーインタラクションの処理を含みます。

完全なコード:


@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 // ロードが完了したかどうか

  @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) {
        // ロード中
        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(()=>{
      // バルブ制御
      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})
  }
}

五、詳細な解析と経験の共有

プルダウンリフレッシュとプルアップロードの実装過程中、データロードの流

Discussion