【Flutter】Riverpodで無限スクロールFetchを実装
はじめに
下記の動画でRiverpodの学習をしている時に出会ったコードです。
今後使う場面がありそうなのでメモとして残しておきます。
環境
$ 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
実装前の準備
プロジェクトの作成
まずはディレクトリを作成します。
ディレクトリ名はなんでも構いません。
mkdir riverpod_infinite_scroll
作成したディレクトリにFlutterのプロジェクトを作成します。
cd riverpod_infinite_scroll/
flutter create .
必要なパッケージを取得
今回必要なパッケージは
- flutter_riverpod
- custom_lint
- riverpod_lint
- riverpod_annotation
- build_runner
- riverpod_generator
になります。
下記のコマンドで全てのパッケージを取得しましょう。
flutter pub add flutter_riverpod dev:custom_lint dev:riverpod_lint riverpod_annotation dev:build_runner dev:riverpod_generator
実行後の`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
を起動します。
flutter pub run build_runner watch
riverpod_lint
の導入(オプション)
ここまでやった方は、すでにriverpod_lint
パッケージが付属しているので、有効にしましょう。
riverpod_lint
を有効にすることでlintのルールやカスタムリファクタリングオプションによりスムーズに開発ができるようになります。
有効にするためにはanalysis_options.yaml
ファイルを下記のように変更します。
analyzer:
plugins:
- custom_lint
実装
それでは実際にコードを書いていきます。
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つだけ表示する
-
fetchItem
で取得したアイテムの数が有限の場合に対応
ローディングテキストは1つだけ表示する
アイテムを50個表示するため、ローディングのTextウィジェットも50個表示されてしまいます。
ローディングのTextウィジェットは1つだけで十分なのでmain.dart
のlaoding
部分を下記のように変更します。
// 中略
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]);
},
);
},
),
// 中略
こうすることでitemIndex
が0
、ようするに50個のアイテムのうち1個だけがローディングの要素として表示されるようになりました。
fetchItem
で取得した数が有限だった場合に対応
現在は無限でスクロールできるようになっていますが、fetch
するものが必ずしも無限とは限りません。
擬似的にfetchItemProvider
を有限にしてみましょう
@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ページ目はa
、b
、final item.
の3要素だけにしてみました。
この状態で動かしてみるとエラーが発生します。
なので以下の処理を追加します。
// 中略
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.builder
はitemCount
がない場合、要素を生成し続けてしまいます。
なので、item
がなくなったら生成を止めるようにnull
を返す必要があります。
最後までスクロールした後、上にスクロールするとバグが発生する
ここまでの実装ができた状態で、一番下までスクロールし、再度一番上にスクロールしようとするとエラーが発生します。
このエラーが発生する原因は以下だと思われます。
- ある程度までスクロールすると
ListView.builder
は表示していないWidgetをキャッシュから削除する - そのため一番下までスクロールすると最初の50個のTextウィジェットはキャッシュから削除される
- 再び上にスクロールすると、最初の50個のfetch処理が始まる
- 本来50個のウィジェットが表示されていたリストが再びfetch処理をするために、ローディングのテキストウィジェット1つしか存在しないというおかしな状況が生まれる
ということでエラーが発生します。
@riverpod
とbuild_runner
で作成したProviderはautoDispose
されるため、再びwatch
した際に再度fetch
処理が実行されます。
なので一度fetch
したものはdispose
されないように明示してあげます。
// 中略
@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
されなくなってもキャッシュしてくれるようになりました。
終わり
いかがだったでしょうか?
あまり見たことがない書き方だったので備忘録として書きました。
何かお気づきな点がありましたらお気軽にコメントしてください。
ここまで読んでいただきありがとうございました!
Discussion