【Flutter】Riverpodを使った無限スクロールの実装例!
こんにちは、プロダクト開発部のwakameです!
はじめての投稿なので、まずは自己紹介をさせていただきます。
私のエンジニア歴は約10年で、これまでおもにモバイルアプリ開発や、バックエンド開発に携わってきました。最近、息子も少しプログラミングに興味を持ち出したので、休日にmicro:bitで一緒に遊んでいます。
Newbees歴は約10ヶ月で、入社してからはFlutterでのモバイルアプリ開発や、Goによるバックエンド開発に携わっています。
Flutterについては、以前から個人的にとても興味があり、入社前にいくつか簡単な個人開発のアプリを作成していました。しかし、本格的なプロダクション開発に参加するのははじめてです。
日々多くのことを学びながら、とても楽しく開発に取り組んでいます。
今回は、そのなかでも特に印象に残っている、FlutterのRiverpodを活用した無限スクロールの実装についてお話します。実際のプロジェクトで導入・検討した実装方法に近い内容なので、是非参考にしてみてください。
無限スクロールとは?
「無限スクロール」とは、ユーザーが画面の1番下までスクロールした際に、自動的に新しいコンテンツがロードされ表示される機能のことを指します。以下のデモ画面をご覧ください。
大量のコンテンツを扱う場合、このような無限スクロールの実装が有効です。ユーザーがシームレスにコンテンツを閲覧できるようになり、体験の向上が期待できます。
Riverpod
Riverpodとは、Flutterにおける状態管理や依存性注入(DI)をサポートするパッケージです。活用シーンが多岐にわたるため、Riverpodのユースケースを一言で説明するのは難しいですが、例として以下のことが実現できます。
本記事では、Riverpodのv2と、v2から導入されたコード生成ツール「riverpod_generator」を使用して実装例をご紹介します。本記事で使用するパッケージのバージョン詳細は、下記のとおりです。
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
google_fonts: 6.1.0
riverpod_annotation: ^2.3.5
shimmer: ^3.0.0
very_good_infinite_list: ^0.7.1
dev_dependencies:
build_runner:
custom_lint: ^0.6.4
flutter_lints: ^2.0.0
riverpod_generator: ^2.4.0
弊社のFlutterアプリは、プロジェクト開始時期の都合でv1を使用しているのですが、riverpod_generator を使用することでProviderの設定方法が統一化され、可読性も上がるため、次回のプロジェクトでは、v2かv3を選択したいですね。
ちなみに、執筆中にv3のプレリリースが発表されました👏
【実装例①】 無限スクロール専用のパッケージを使用する
それでは実装例に入ります。1つ目は、Very Good Ventures(VGV)が開発した無限スクロール専用のパッケージであるvery_good_infinite_list を使用する例です。実は、このパッケージを導入する以前は、同じようなプログラム構成で自前でUI部分の実装もおこなっていたのですが、パッケージを導入したことでコードが簡潔になりました。
VGVは多くのFlutterパッケージを開発していますが、パッケージの実装がシンプルなものが多く、内部実装が理解しやすいのでとても助かっています。(今回のvery_good_infinite_listも、リスト部分の主要実装であるsliver_infinite_list.dartは200行程度です)
実装
コード全体は以下の通りです。
/// (1) LoadingStateは非同期操作の状態を管理するためのクラス。
@riverpod
class LoadingState extends _$LoadingState {
@override
AsyncValue<void> build() {
return const AsyncValue.data(null);
}
void loading() {
state = const AsyncValue.loading();
}
void update(AsyncValue<void> value) {
state = value;
}
}
@immutable
class CouponList {
const CouponList({
required this.urls,
required this.hasReachedMax,
required this.currentPage,
});
final List<String> urls;
final int currentPage;
final bool hasReachedMax;
}
/// (2) CouponListControllerは、APIから取得するクーポンリストの状態を管理するクラス
@riverpod
class CouponListController extends _$CouponListController {
@override
CouponList build() {
return const CouponList(urls: [], currentPage: 0, hasReachedMax: false);
}
Future<void> fetch() async {
ref.read(loadingStateProvider.notifier).loading();
final value = await AsyncValue.guard(() async {
// `fetchCouponUrls`は指定されたページに基づいてクーポンのURLリストを非同期に取得する関数
final results = await fetchCouponUrls(state.currentPage + 1);
state = CouponList(
urls: state.urls + results,
currentPage: state.currentPage + 1,
hasReachedMax: results.isEmpty);
});
ref.read(loadingStateProvider.notifier).update(value);
}
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final loadingState = ref.watch(loadingStateProvider);
final coupon = ref.watch(couponListControllerProvider);
return MaterialApp(
home: Scaffold(
backgroundColor: const Color.fromRGBO(210, 200, 110, 1),
appBar: AppBar(title: const Text('Example')),
// (3) very_good_infinite_list
body: InfiniteList(
itemCount: coupon.urls.length,
hasReachedMax: coupon.hasReachedMax,
onFetchData: ref.read(couponListControllerProvider.notifier).fetch,
isLoading: loadingState.isLoading,
itemBuilder: (context, index) {
// CouponBoxはスクロール内コンテンツのUI (実装は割愛)
return CouponBox(url: coupon.urls[index]);
}),
),
);
}
}
何点かポイントを解説します。
(1)、(2)のLoadingState と CouponListControllerは、それぞれ、loadingStateProviderとcouponListControllerProviderのProviderを生成するための関数です。riverpod_generatorを使用する場合、このように@riverpodアノテーションを付与してbuild_runnerでコード生成をおこないます。
上記で生成されるloadingStateProviderとcouponListControllerProviderは、変更可能な状態を管理するためのNotifierProviderになります。
Riverpodでアプリの状態を動的に管理できるProviderは、他にもStateProvider、StateNotifierProvider、ChangeNotifierProviderがありますが、v2時点ではこれらすべてが非推奨となっています。(3)がvery_good_infiinite_listの利用箇所です。引数を見るだけで、おおよその使い方が想像できるのではないでしょうか。
また、ここでは指定していませんが、エラーハンドリングをおこなう場合には、HasErrorとerrorBuilderを活用します。他にも便利なオプションがあるので、ぜひREADMEを確認してみてください。
再掲になりますが、こちらが上記の実装を動かしたときの様子です。
【実装例②】 Riverpodのキャッシュ機能を使った実装
もう一点、Riverpodのキャッシュ機能を使った実装例もご紹介します。
こちらの実装方法は、Riverpodの作者であるRemiさんが、2022年に FlutterVikingsというイベントで発表されていた手法です。弊社のプロジェクト内で導入には至りませんでしたが、Riverpodのキャッシュの活用方法などを参考にさせて頂きました。
以下のブログ記事でも、この実装方法について詳細に扱っています。無限スクロールの他に、検索機能やキャッシュの仕組みも詳しく解説されているので、興味のある方は確認してみてください。
https://codewithandrea.com/articles/flutter-riverpod-pagination/
実装
こちらも、コード全体を掲載します。
/// (1) クーポン取得用のFutureProvider生成用の関数
/// `page` パラメータによってfamily化されている
///
/// [FetchCouponsRef] ref - Riverpodのrefオブジェクト
/// [int] page - クーポンデータを取得するページ番号。
@riverpod
Future<List<String>> fetchCoupons(FetchCouponsRef ref, int page) async {
// keepAliveによりこのProviderはautoDisposeされなくなる
final link = ref.keepAlive();
Timer? timer;
// このProviderがのリスナーが1つも無くなったときにタイマーを開始
// タイマー時間経過後, 明示的に本Providerを破棄する (link.close())
ref.onCancel(() {
timer?.cancel();
timer = Timer(const Duration(seconds: 10), () {
link.close();
});
});
// onCancel後に再度リスナー登録されたらタイマーをキャンセル
ref.onResume(() {
timer?.cancel();
});
// Providerがdisposeされたらタイマーを破棄
ref.onDispose(() {
timer?.cancel();
});
// `fetchCouponUrls`は指定されたページに基づいてクーポンのURLリストを非同期に取得する関数
return fetchCouponUrls(page);
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
static const pageSize = 5;
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
home: Scaffold(
backgroundColor: const Color.fromRGBO(210, 200, 110, 1),
appBar: AppBar(title: const Text('Example')),
body: Column(
children: [
Expanded(
child: ListView.builder(
// (2) ListView内の各アイテムを返却するビルダー
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
final AsyncValue<List<String>> responseAsync =
ref.watch(fetchCouponsProvider(page));
return responseAsync.when(
error: (err, stack) => Text(err.toString()),
loading: () => const CouponBoxShimmer(),
data: (response) {
if (indexInPage >= response.length) {
// (3) ページ内インデックスがAPIレスポンスの
// 長さを超えた場合, 無限スクロールを終了する
return null;
}
final str = response[indexInPage];
return CouponBox(url: str);
},
);
},
),
),
],
)),
);
}
}
上記の実装で動かしたときの動画がこちらになります。
実装のポイントを以下にまとめます。
(1)のfetchCoupons関数は、クーポンのURLリストを非同期に取得するFutureProviderを生成します。関数内のコメントでも注釈を入れていますが、KeepAliveを使って同ページへのAPIリクエスト結果を一定時間キャッシュする仕組みになっています。
(2)のビルダーでは、引数indexを使って、 現在のページとそのページ内の位置を計算し、fetchCouponsProviderに渡してAPIからデータ取得します。
パッと見ると、スクロールするたびにすべてのアイテムでAPIコールが発生するように見えますが、上記で説明した通り、RiverpodがAPI結果をページ毎にキャッシュするため、スクロール時にすべてのアイテムでAPIコールが発生するのを回避しています。(3)では、ページ内インデックスがAPIレスポンスのURLリストの長さを超えた場合、つまり、APIが返却するリストが最後尾に達し、スクロールしてもコンテンツが存在しない場合にreturn nullをしています。 ListViewは、itemCountを指定しない場合無限にスクロールしますが、itemBuilderがnullを返却すると、itemBuilderの呼び出しを停止するため、これによりスクロールを終了することができます。
Riverpodのキャッシュを上手く利用していて面白い実装ですが、注意点があります。
この実装では、終端判定(return null)をおこなうタイミングがレスポンス返却後になるので、ローディング中はどこが終端か判定できません。そのため、ユーザーが素早くスクロールをし続けた場合、いつまでもローディング中のアイテムが表示され続けてしまいます。
一般的な業務アプリではこの挙動は好ましくないため、本格的な実装では、事前にリストの終端を把握できるよう、API側と合わせて調整が必要になるかもしれません。
まとめ
今回は、FlutterのRiverpodを活用した無限スクロールの実装例を2つ紹介させていただきました。
Flutterは最近、3.22のリリースが発表され、WebAssembly(Wasm)ビルドがstable channelで利用可能となりました。また、Dart3.4で試験的にマクロ機能が導入されるなど、毎回のリリースで新しいニュースが続いています。これからも、Flutterの進化を楽しみにしつつ、機能のキャッチアップをしていきたいと思います。
Newbeesでは一緒に働く仲間を募集しています
フルリモート&フレックス勤務を導入し、場所にとらわれない自由な仕事のやり方が可能です。詳細は以下をご覧ください。