🐙

Riverpod Generatorに対応したページング高速実装の仕組みを作ってみた

2024/05/12に公開
1

はじめに

以前Riverpodでページングに対応した画面の汎用実装の記事を投稿しました。

https://zenn.dev/k9i/articles/b8c333e1bb8b9b

当時は今ほどRiverpod Generatorが広まってなかった(たぶん…)ため、Generator非対応の実装でした。
今回Generator対応版の実装を考え、ついでにパッケージ化もしてみたので続編としてこの記事で紹介します👍

デモ

こういう画面が簡単に作れます!

パッケージ紹介

riverpod_paging_utilsという名前にしました。

https://pub.dev/packages/riverpod_paging_utils

使い方

今回はカーソルベースのページングをします。

ますはProviderを定義します。
いつものクラスベースのProviderにCursorPagingNotifierMixinを追記します。
そうするとfetchというメソッドのoverrideを要求されるので、引数のcursorを使ってCursorPagingDataを返す実装をします。
また最初のページ取得はbuildで行うので、watchしたいProviderとかがなければ先ほどのfetchを呼びます。

// 1. CursorPagingNotifierMixinをwith

class SampleNotifier extends _$SampleNotifier with CursorPagingNotifierMixin {

  // 3. fetchを使うと楽
  
  Future<CursorPagingData<SampleItem>> build() => fetch(cursor: null);

  // 2. fetchをoverride
  
  Future<CursorPagingData<SampleItem>> fetch({
    required String? cursor,
  }) async {
    final repository = ref.read(sampleRepositoryProvider);
    final (items, nextCursor) = await repository.getByCursor(cursor);
    final hasMore = nextCursor != null && nextCursor.isNotEmpty;

    return CursorPagingData(
      items: items,
      hasMore: hasMore,
      nextCursor: nextCursor,
    );
  }
}

次に画面の実装です。
PagingHelperViewというWidgetを使います。
providerに先ほど定義したsampleNotifierProviderを渡します。
contentBuilderはdata(表示用のデータ)とendItemView(ページング処理中に表示される末尾のWidget)を引数にWidgetを返すbuilderです。ListViewなどを返すようにします。

class SamplePage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample Page'),
      ),
      body: PagingHelperView(
        provider: sampleNotifierProvider,
        contentBuilder: (data, endItemView) => ListView.builder(
          itemCount: data.items.length + (endItemView != null ? 1 : 0),
          itemBuilder: (context, index) {
            if (endItemView != null && index == data.items.length) {
              return endItemView;
            }
            return ListTile(
              title: Text(data.items[index].name),
              subtitle: Text(data.items[index].id),
            );
          },
        ),
      ),
    );
  }
}

ここまでこのような画面が作れます。

機能的には

  • 1ページ目
    • データ取得に成功したらリスト表示
    • 読み込み中は全面ローディング表示
    • エラー時は全面エラー画面+スナックバーでエラーを伝える
    • 全面エラー画面にはリトライボタンを表示する
  • 2ページ目以降
    • リストの最下部に到達時に読み込み開始
    • データ取得に成功したらリスト表示
    • 読み込み中は最下部にローディング表示
    • エラー時は取得済みデータの表示を維持+スナックバーでエラーを伝える
  • その他
    • Pull to Refreshで最初のページから読み直せる
    • Pull to Refresh中は元の表示を維持する
      となってます。

パッケージの中身

主に3種類のクラス(とmixin)から成り立ってます。

〇〇PagingData

Paging用のデータが入るクラスです。〇〇にはPage、Offset、Cursorの3種類があります。
PagingDataが共通でitemsとhasMoreを持ちます。CursorPagingDataなどは追加でnextCursorなどページング方式に応じたフィールドを持ちます。
現状freezedを使っていますが、freezedの依存をなくした方がいいかな〜と思っています。

abstract class PagingData<T> {
  List<T> get items;
  bool get hasMore;
}


class CursorPagingData<T> with _$CursorPagingData<T> implements PagingData<T> {
  const factory CursorPagingData({
    required List<T> items,
    required bool hasMore,
    required String? nextCursor,
  }) = _CursorPagingData;
}

〇〇PagingNotifierMixin

Providerの定義に使うMixinです。loadNextがメインで、これは2ページ目以降の取得で使います。
ローディング、エラーなどをよしなにやってくれるようにしてます。
fetchの引数がページング方式で変わるので、ページング方式ごとにMixinを実装してます。

mixin CursorPagingNotifierMixin<T>
    on AutoDisposeAsyncNotifier<CursorPagingData<T>> {
  Future<CursorPagingData<T>> fetch({required String? cursor});

  Future<void> loadNext() async {
    final value = state.valueOrNull;
    if (value == null || state.hasError) {
      return;
    }

    if (value.hasMore) {
      state = AsyncLoading<CursorPagingData<T>>().copyWithPrevious(state);

      state = await state.guardPreservingPreviousOnError(
        () async {
          final next = await fetch(cursor: value.nextCursor);

          return value.copyWith(
            items: [...value.items, ...next.items],
            nextCursor: next.nextCursor,
            hasMore: next.hasMore,
          );
        },
      );
    }
  }

  void forceRefresh() {
    state = AsyncLoading<CursorPagingData<T>>();
    ref.invalidateSelf();
  }
}

extension _AsyncValueX<T> on AsyncValue<T> {
  Future<AsyncValue<T>> guardPreservingPreviousOnError(
    Future<T> Function() future,
  ) async {
    try {
      return AsyncValue.data(await future());
    } on Exception catch (err, stack) {
      return AsyncValue<T>.error(err, stack).copyWithPrevious(this);
    }
  }
}

PagingHelperView

ページング画面を実装するためのWidgetです。
基本providerを渡すだけでよしなにやってくれます。
最初のassertはMixinをちゃんと実装してるかチェックしてます。Genericsでできないか調べたけどわからなかった🥺
listen部分は主に2ページ目以降のエラーでdataを表示しつつスナックバーでエラーを表示しています。
_EndItemViewは2ページ目以降のローディングで表示されるくるくるです。VisibilityDetectorでonScrollEndを呼ぶようにしており、パターンマッチでMixinに応じたloadNextを読んでいます。
現状エラーのカスタマイズができないので改良したい😅

class PagingHelperView<N extends AutoDisposeAsyncNotifier<D>,
    D extends PagingData<I>, I> extends ConsumerWidget {
  const PagingHelperView({
    required this.provider,
    required this.contentBuilder,
    super.key,
  });

  final AutoDisposeAsyncNotifierProvider<N, D> provider;

  final Widget Function(D data, Widget? endItemView) contentBuilder;

  
  Widget build(BuildContext context, WidgetRef ref) {
    assert(
      ref.read(provider.notifier) is PagePagingNotifierMixin ||
          ref.read(provider.notifier) is OffsetPagingNotifierMixin ||
          ref.read(provider.notifier) is CursorPagingNotifierMixin,
      'The notifier must implement PagePagingNotifierMixin, OffsetPagingNotifierMixin, or CursorPagingNotifierMixin',
    );

    ref.listen(provider, (_, state) {
      if (!state.isLoading && state.hasError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              state.error!.toString(),
            ),
          ),
        );
      }
    });

    return ref.watch(provider).whenIgnorableError(
          data: (data, {required hasError}) {
            return RefreshIndicator(
              onRefresh: () => ref.refresh(provider.future),
              child: contentBuilder(
                data,
                data.hasMore && !hasError
                    ? _EndItemView(
                        onScrollEnd: () {
                          switch (ref.read(provider.notifier)) {
                            case (final PagePagingNotifierMixin pageNotifier):
                              pageNotifier.loadNext();
                            case (final OffsetPagingNotifierMixin
                                  offsetNotifier):
                              offsetNotifier.loadNext();
                            case (final CursorPagingNotifierMixin
                                  cursorNotifier):
                              cursorNotifier.loadNext();
                          }
                        },
                      )
                    : null,
              ),
            );
          },
          loading: () => const Center(
            child: CircularProgressIndicator(),
          ),
          error: (e, st) => Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                IconButton(
                  onPressed: () {
                    switch (ref.read(provider.notifier)) {
                      case (final PagePagingNotifierMixin pageNotifier):
                        pageNotifier.forceRefresh();
                      case (final OffsetPagingNotifierMixin offsetNotifier):
                        offsetNotifier.forceRefresh();
                      case (final CursorPagingNotifierMixin cursorNotifier):
                        cursorNotifier.forceRefresh();
                    }
                  },
                  icon: const Icon(Icons.refresh),
                ),
                Text(e.toString()),
              ],
            ),
          ),
          skipErrorOnHasValue: true,
        );
  }
}

class _EndItemView extends StatelessWidget {
  const _EndItemView({
    required this.onScrollEnd,
  });
  final VoidCallback onScrollEnd;

  
  Widget build(BuildContext context) {
    return VisibilityDetector(
      key: key ?? const Key('EndItem'),
      onVisibilityChanged: (info) {
        if (info.visibleFraction > 0.1) {
          onScrollEnd();
        }
      },
      child: const Center(
        child: Padding(
          padding: EdgeInsets.all(16),
          child: CircularProgressIndicator(),
        ),
      ),
    );
  }
}

extension _AsyncValueX<T> on AsyncValue<T> {
  R whenIgnorableError<R>({
    required R Function(T data, {required bool hasError}) data,
    required R Function(Object error, StackTrace stackTrace) error,
    required R Function() loading,
    bool skipLoadingOnReload = false,
    bool skipLoadingOnRefresh = true,
    bool skipError = false,
    bool skipErrorOnHasValue = false,
  }) {
    if (skipErrorOnHasValue) {
      if (hasValue && hasError) {
        return data(requireValue, hasError: true);
      }
    }

    return when(
      skipLoadingOnReload: skipLoadingOnReload,
      skipLoadingOnRefresh: skipLoadingOnRefresh,
      skipError: skipError,
      data: (d) => data(d, hasError: hasError),
      error: error,
      loading: loading,
    );
  }
}

まとめ

riverpod_paging_utilsを紹介しました。
ローディングやエラー画面のカスタマイズなどできるようにアプデしていきたいと思っています。
気が向いたらリポジトリに⭐もらえると嬉しいです〜

https://github.com/K9i-0/riverpod_paging_utils

パッケージをそのまま使うほか、似たようなことをしたい時の参考にしてください👍

GitHubで編集を提案
株式会社ゆめみ

Discussion