🐙
Riverpodでページングに対応した画面を爆速で実装する仕組みを作る
前提
- アプリにおけるページング対応画面の仕様が決まっている場合、汎用実装を用意することで繰り返しの実装が楽になります。
- 今回は架空の仕様の元、ページング対応画面を簡単に実装するための汎用実装を作ります。
画面仕様
以下のような仕様
- 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),
);
},
),
);
}
}
まとめ
- アプリにおけるページング対応画面の仕様が決まっていることが前提になりますが、汎用クラスを使うことで簡単にページング対応画面が簡単に実装できるようになりました。
ソースコード
Zapp!で試す
Discussion
パジネーション読み込みの loadNext のタイミングを今まで
ScrollNotification
でやることが多く、いつも微調整が難しいなと思っていたところだったので、VisibilityDetector.onVisibilityChanged
をトリガーにする点とても勉強になりました!🙏