👀

【Flutter】Riverpodで無限スクロールFetchを実装

2023/05/23に公開

はじめに

下記の動画でRiverpodの学習をしている時に出会ったコードです。
今後使う場面がありそうなのでメモとして残しておきます。

https://www.youtube.com/watch?v=BJtQ0dfI-RA

環境

terminal
$ flutter doctor
[] Flutter (Channel stable, 3.10.1, on macOS 13.2.1 22D68 darwin-arm64, locale ja-JP)
[] Android toolchain - develop for Android devices (Android SDK version 32.1.0-rc1)
[] Xcode - develop for iOS and macOS (Xcode 14.3)
[] Chrome - develop for the web
[] Android Studio (version 2022.1)
[] VS Code (version 1.78.2)
[] Connected device (3 available)
[] Network resources

完成物

gif

実装前の準備

プロジェクトの作成

まずはディレクトリを作成します。
ディレクトリ名はなんでも構いません。

terminal
mkdir riverpod_infinite_scroll

作成したディレクトリにFlutterのプロジェクトを作成します。

terminal
cd riverpod_infinite_scroll/
flutter create .

必要なパッケージを取得

今回必要なパッケージは

  • flutter_riverpod
  • custom_lint
  • riverpod_lint
  • riverpod_annotation
  • build_runner
  • riverpod_generator

になります。
下記のコマンドで全てのパッケージを取得しましょう。

terminal
flutter pub add flutter_riverpod dev:custom_lint dev:riverpod_lint riverpod_annotation dev:build_runner dev:riverpod_generator
実行後の`pubspec.yaml`
pubspec.yaml
# 中略

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_riverpod: ^2.3.6
  riverpod_annotation: ^2.1.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  custom_lint: ^0.4.0
  riverpod_lint: ^1.3.2
  build_runner: ^2.4.4
  riverpod_generator: ^2.2.3

# 中略

コードジェネレーターの実行

@riverpodを使用してコードを生成するためにbuild_runnerを起動します。

terminal
flutter pub run build_runner watch

riverpod_lintの導入(オプション)

ここまでやった方は、すでにriverpod_lintパッケージが付属しているので、有効にしましょう。
riverpod_lintを有効にすることでlintのルールやカスタムリファクタリングオプションによりスムーズに開発ができるようになります。

有効にするためにはanalysis_options.yamlファイルを下記のように変更します。

analysis_options.yaml
analyzer:
  plugins:
    - custom_lint

実装

それでは実際にコードを書いていきます。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'main.g.dart';

void main() {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}


Future<List<String>> fetchItem(
  FetchItemRef ref, {
  required int page,
}) async {
  await Future.delayed(const Duration(seconds: 3));
  return List.generate(50, (index) => 'Hello $page $index');
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Home(),
    );
  }
}

class Home extends ConsumerWidget {
  const Home({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Infinite List Example.')),
      body: ListView.builder(
        itemBuilder: (context, index) {
          final page = index ~/ 50;
          final itemIndex = index % 50;

          final pageValue = ref.watch(fetchItemProvider(page: page));

          return pageValue.when(
            error: (error, stackTrace) => Text('error: $error'),
            loading: () => Text("Loading..."),
            data: (items) {
              return Text(items[itemIndex]);
            },
          );
        },
      ),
    );
  }
}

この状態でmain.dartを保存すると、build_runnerが動いてmain.g.dartが生成されると思います。

確認する

デバッグビルドで確認してみましょう。
下記のように無限スクロールができていれば完成です。

次に以下の修正を加えます。

  1. ローディングテキストは1つだけ表示する
  2. fetchItemで取得したアイテムの数が有限の場合に対応

ローディングテキストは1つだけ表示する

アイテムを50個表示するため、ローディングのTextウィジェットも50個表示されてしまいます。
ローディングのTextウィジェットは1つだけで十分なのでmain.dartlaoding部分を下記のように変更します。

main.dart
// 中略
ListView.builder(
  itemBuilder: (context, index) {
    final page = index ~/ 50;
    final itemIndex = index % 50;

    final pageValue = ref.watch(fetchItemProvider(page: page));

    return pageValue.when(
      error: (error, stackTrace) => Text('error: $error'),
      loading: () {
+       if (itemIndex != 0) return null;
        return Text("Loading...");
      },
      data: (items) {
        return Text(items[itemIndex]);
      },
    );
  },
),
// 中略

こうすることでitemIndex0、ようするに50個のアイテムのうち1個だけがローディングの要素として表示されるようになりました。

fetchItemで取得した数が有限だった場合に対応

現在は無限でスクロールできるようになっていますが、fetchするものが必ずしも無限とは限りません。
擬似的にfetchItemProviderを有限にしてみましょう

main.dart
@riverpod
Future<List<String>> fetchItem(
  FetchItemRef ref, {
  required int page,
}) async {
+ if (2 < page) return ['a', 'b', 'final item.'];
  await Future.delayed(const Duration(seconds: 3));
  return List.generate(50, (index) => 'Hello $page $index');
}

2ページ目はabfinal item.の3要素だけにしてみました。
この状態で動かしてみるとエラーが発生します。

なので以下の処理を追加します。

main.dart
// 中略
ListView.builder(
  itemBuilder: (context, index) {
    final page = index ~/ 50;
    final itemIndex = index % 50;

    final pageValue = ref.watch(fetchItemProvider(page: page));

    return pageValue.when(
      error: (error, stackTrace) => Text('error: $error'),
      loading: () {
        if (itemIndex != 0) return null;
        return Text("Loading...");
      },
      data: (items) {
+       if (items.length <= itemIndex) return null;
        return Text(items[itemIndex]);
      },
    );
  },
),
// 中略

ListView.builderitemCountがない場合、要素を生成し続けてしまいます。
なので、itemがなくなったら生成を止めるようにnullを返す必要があります。

最後までスクロールした後、上にスクロールするとバグが発生する

ここまでの実装ができた状態で、一番下までスクロールし、再度一番上にスクロールしようとするとエラーが発生します。
このエラーが発生する原因は以下だと思われます。

  1. ある程度までスクロールするとListView.builderは表示していないWidgetをキャッシュから削除する
  2. そのため一番下までスクロールすると最初の50個のTextウィジェットはキャッシュから削除される
  3. 再び上にスクロールすると、最初の50個のfetch処理が始まる
  4. 本来50個のウィジェットが表示されていたリストが再びfetch処理をするために、ローディングのテキストウィジェット1つしか存在しないというおかしな状況が生まれる

ということでエラーが発生します。
@riverpodbuild_runnerで作成したProviderはautoDisposeされるため、再びwatchした際に再度fetch処理が実行されます。
なので一度fetchしたものはdisposeされないように明示してあげます。

main.dart
// 中略
@riverpod
Future<List<String>> fetchItem(
  FetchItemRef ref, {
  required int page,
}) async {
  if (2 < page) return ['a', 'b', 'final item.'];
  await Future.delayed(const Duration(seconds: 3));
+ ref.keepAlive();

  return List.generate(50, (index) => 'Hello $page $index');
}
// 中略

こうすることで一度watchされたら、watchされなくなってもキャッシュしてくれるようになりました。

終わり

いかがだったでしょうか?
あまり見たことがない書き方だったので備忘録として書きました。
何かお気づきな点がありましたらお気軽にコメントしてください。
ここまで読んでいただきありがとうございました!

GitHubで編集を提案

Discussion