見出し画像

【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では一緒に働く仲間を募集しています

フルリモート&フレックス勤務を導入し、場所にとらわれない自由な仕事のやり方が可能です。詳細は以下をご覧ください。