🐙

Riverpodでページングに対応した画面を爆速で実装する仕組みを作る

2023/02/02に公開1

前提

  • アプリにおけるページング対応画面の仕様が決まっている場合、汎用実装を用意することで繰り返しの実装が楽になります。
  • 今回は架空の仕様の元、ページング対応画面を簡単に実装するための汎用実装を作ります。

画面仕様

以下のような仕様

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

技術面

  • RiverpodのAsyncValueに乗っかる
  • 以下の三つのページング方式に対応させる
    • Page based paging
    • Offset based paging
    • Cursor based paging
  • データ保持用クラスにfreezedを使う

汎用クラスの準備

以下の4つを実装していきます

  • データ保持用基底クラス
  • Notifier用基底クラス
  • 汎用ページングWidget
  • AsyncValue拡張

データ保持用基底クラス

  • T型のクラスのリストを持つPageBasedPagingDataを作成します。他のページング方式も同様に作ります。
  • (記事ではPage based pagingのクラスだけ載せています)
paging_data.dart
// ignore: unused_import, directives_ordering
import 'package:freezed_annotation/freezed_annotation.dart';

part 'paging_data.freezed.dart';

/// PagingDataのitemの基底クラス
/// [id]を持つことを強制する
abstract class PagingDataItem {
  String get id;
}

/// 汎用ページングWidgetを使うための基底クラス
abstract class PagingData<T extends PagingDataItem> {
  List<T> get items;
  bool get hasMore;
}


class PageBasedPagingData<T extends PagingDataItem>
    with _$PageBasedPagingData<T>
    implements PagingData<T> {
  const PageBasedPagingData._();
  const factory PageBasedPagingData({
    required List<T> items,
    required int page,
    required bool hasMore,
  }) = _PageBasedPagingData<T>;
}

Notifier用基底クラス

  • fetchNextを実装する必要があるPageBasedPagingAsyncNotifierを作ります。PagingAsyncNotifierを継承することで、後の汎用Widgetがすべてのページング形式に対応します。
paging_async_notifier.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_paging_sample/async_value_extension.dart';
import 'package:riverpod_paging_sample/paging_data.dart';

abstract class PagingAsyncNotifier<T extends PagingData>
    extends AutoDisposeAsyncNotifier<T> {
  Future<void> loadNext();

  // 状態を破棄して再読み込みする
  void forceRefresh() {
    state = AsyncLoading<T>();
    ref.invalidateSelf();
  }
}

/// PageBasedPagingを実装するためのNotifier
/// [build]と[fetchNext]をoverrideすることで、ローディングやエラーが勝手に処理される
abstract class PageBasedPagingAsyncNotifier<T extends PagingDataItem>
    extends PagingAsyncNotifier<PageBasedPagingData<T>> {
  /// 2ページ目以降のデータを取得するメソッド
  /// [PageBasedPagingAsyncNotifier]を継承したクラス内(もっと言えば[loadNext])からしか呼ばない想定
  Future<PageBasedPagingData<T>> fetchNext(int page);

  /// 2ページ目以降のデータを取得する
  /// 基本的にoverrideする必要はない
  
  Future<void> loadNext() async {
    // データがない時は何もしない
    final value = state.valueOrNull;
    if (value == null) {
      return;
    }
    // エラーがある時は何もしない
    if (state.hasError) {
      return;
    }

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

      state = await state.guardPlus(
        () async {
          final next = await fetchNext(value.page + 1);

          return value.copyWith(
            items: [...value.items, ...next.items],
            page: value.page + 1,
            hasMore: next.hasMore,
          );
        },
      );
    }
  }
}

汎用ページングWidget

  • 始めに書いた画面仕様を簡単に満たせる汎用Widgetを作ります。
  • エラーWidgetなどを引数から調整できるようにしても良いかもしれません。
common_paging_view.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_paging_sample/async_value_extension.dart';
import 'package:riverpod_paging_sample/paging_async_notifier.dart';
import 'package:riverpod_paging_sample/paging_data.dart';
import 'package:visibility_detector/visibility_detector.dart';

/// ページングのための汎用Widget
///
/// 主な機能
/// 1. データがある場合は、[contentBuilder]で作ったWidgetを表示する
/// 2. 1ページの読み込み中は、CircularProgressIndicatorを表示する
/// 3. 1ページ目のエラー時は、エラーWidgetを表示する
/// 4. エラー時にスナックバーでエラーを表示する
/// 5. 最後のアイテムが表示されたら、次のページを読み込む
/// 6. Pull to Refreshに対応する
class CommonPagingView<
    N extends PagingAsyncNotifier<D>,
    D extends PagingData<I>,
    I extends PagingDataItem> extends HookConsumerWidget {
  /// [PagingAsyncNotifier]を実装したクラスのProviderを指定する
  final AutoDisposeAsyncNotifierProvider<N, D> provider;

  /// データがある場合に表示するWidgetを返す関数を指定する
  /// [endItem]は最後に表示されたアイテムが表示されたことを検知するためのWidgetで、non nullの時にリストの最後に表示する
  final Widget Function(D data, Widget? endItem) contentBuilder;
  const CommonPagingView({
    required this.provider,
    required this.contentBuilder,
    super.key,
  });

  
  Widget build(BuildContext context, WidgetRef ref) {
    // スナックバーによるエラー表示
    ref.listen(provider, (_, state) {
      state.showSnackbarOnError(context);
    });

    return ref.watch(provider).whenPlus(
          data: (data, hasError) {
            return RefreshIndicator(
              onRefresh: () => ref.refresh(provider.future),
              child: contentBuilder(
                data,
                // 次のページがあり、かつエラーがない場合に、最後の要素に達したことを検知するためのWidgetを表示する
                data.hasMore && !hasError
                    ? EndItem(
                        onScrollEnd: () =>
                            ref.read(provider.notifier).loadNext(),
                      )
                    : null,
              ),
            );
          },
          // 1ページ目のロード中
          loading: () => const Center(
            child: CircularProgressIndicator(),
          ),
          // 1ページ目のエラー
          error: (e, st) => Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                IconButton(
                  onPressed: () => ref.read(provider.notifier).forceRefresh(),
                  icon: const Icon(Icons.refresh),
                ),
                Text(e.toString()),
              ],
            ),
          ),
          // 2ページ目以降のエラーでデータを優先する
          skipErrorOnHasValue: true,
        );
  }
}

class EndItem extends StatelessWidget {
  final VoidCallback onScrollEnd;
  const EndItem({
    super.key,
    required this.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(),
        ),
      ),
    );
  }
}

AsyncValue拡張

  • 標準のAsyncValueの機能と近い使用感を維持しつつ機能を増やした拡張を作ります。
async_value_extension.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

extension AsyncValueX<T> on AsyncValue<T> {
  /// guard関数の拡張版
  /// 例外時に前回のデータを持たせてエラーを返す
  Future<AsyncValue<T>> guardPlus(Future<T> Function() future) async {
    try {
      return AsyncValue.data(await future());
    } catch (err, stack) {
      // 前回のデータを持たせてエラーを返す
      return AsyncValue<T>.error(err, stack).copyWithPrevious(this);
    }
  }

  /// when関数の拡張版
  ///
  /// [skipErrorOnHasValue]がtrueの時はデータがある場合のエラーをスキップする
  /// ページングの2ページ目以降でエラー時に、取得ずみデータを表示する場合などに使用する
  R whenPlus<R>({
    bool skipLoadingOnReload = false,
    bool skipLoadingOnRefresh = true,
    bool skipError = false,
    bool skipErrorOnHasValue = false,
    required R Function(T data, bool hasError) data,
    required R Function(Object error, StackTrace stackTrace) error,
    required R Function() loading,
  }) {
    if (skipErrorOnHasValue) {
      if (hasValue && hasError) {
        return data(requireValue, true);
      }
    }

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

  /// エラー時にスナックバーを表示する
  void showSnackbarOnError(
    BuildContext context, {
    String defaultMessage = "エラーが発生しました",
  }) {
    if (!isLoading && hasError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(
            error!.toString(),
          ),
        ),
      );
    }
  }
}

汎用クラスを使ってみる

  • 実際に汎用クラスを使ってページング画面を実装します。

リストのItemに対応するクラス

  • PagingDataItemを実装したクラスを用意します。
sample_item.dart
// ignore: unused_import, directives_ordering
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_paging_sample/paging_data.dart';

part 'sample_item.freezed.dart';

/// PagingDataItemを実装したクラス

class SampleItem with _$SampleItem implements PagingDataItem {
  const factory SampleItem({
    required String id,
    required String name,
  }) = _SampleItem;
}

StateとNotifier

  • Notifier用基底クラスを継承したNotifierを実装します。buildに1ページ目の取得処理、fetchNextに2ページ目以降の取得処理を書けば終わりです。
  • NotifierのもつStateはtypedefで定義しています。
page_based_sample_notifier.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_paging_sample/example/sample/sample_item.dart';
import 'package:riverpod_paging_sample/example/sample/sample_repository.dart';
import 'package:riverpod_paging_sample/paging_async_notifier.dart';
import 'package:riverpod_paging_sample/paging_data.dart';

final pageBasedSampleNotifierProvider = AsyncNotifierProvider.autoDispose<
    PageBasedSampleNotifier, PageBasedSampleState>(
  () => PageBasedSampleNotifier(),
);

typedef PageBasedSampleState = PageBasedPagingData<SampleItem>;

class PageBasedSampleNotifier extends PageBasedPagingAsyncNotifier<SampleItem> {
  /// 1ページ目の取得処理
  
  Future<PageBasedSampleState> build() async {
    final res = await ref.read(sampleRepositoryProvider).getByPage();
    ref.keepAlive();

    return PageBasedSampleState(
      items: res //
          .items,
      page: 0,
      hasMore: res.hasMore,
    );
  }

  /// 2ページ目以降の取得処理
  /// エラーハンドリングなどはPageBasedPagingAsyncNotifier側でよしなに行われるので、ここでは取得処理のみを記述する
  
  Future<PageBasedSampleState> fetchNext(int page) async {
    final res =
        await ref.read(sampleRepositoryProvider).getByPage(page: page + 1);
    ref.keepAlive();

    return PageBasedSampleState(
      items: res //
          .items,
      page: page + 1,
      hasMore: res.hasMore,
    );
  }
}

UI

  • CommonPagingViewに先ほど作ったNotifierのProviderを渡します。
page_based_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:riverpod_paging_sample/common_paging_view.dart';
import 'package:riverpod_paging_sample/example/sample/page_based_sample_notifier.dart';

/// Page based Pagingのサンプル
class PageBasedView extends HookWidget {
  const PageBasedView({super.key});

  
  Widget build(BuildContext context) {
    return CommonPagingView(
      provider: pageBasedSampleNotifierProvider,
      contentBuilder: (data, endItem) => ListView.builder(
        key: const PageStorageKey('pageBasedView'),
        itemCount: data.items.length + (endItem != null ? 1 : 0),
        itemBuilder: (context, index) {
          if (endItem != null && index == data.items.length) {
            return endItem;
          }

          return ListTile(
            title: Text(data.items[index].name),
            subtitle: Text(data.items[index].id),
          );
        },
      ),
    );
  }
}

まとめ

  • アプリにおけるページング対応画面の仕様が決まっていることが前提になりますが、汎用クラスを使うことで簡単にページング対応画面が簡単に実装できるようになりました。

ソースコード

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

Zapp!で試す

https://zapp.run/github/K9i-0/riverpod_paging_sample

GitHubで編集を提案

Discussion

Kosuke SaigusaKosuke Saigusa

パジネーション読み込みの loadNext のタイミングを今まで ScrollNotification でやることが多く、いつも微調整が難しいなと思っていたところだったので、VisibilityDetector.onVisibilityChanged をトリガーにする点とても勉強になりました!🙏