注意
riverpod: ^2.0.0 を前提とします
riverpod と AsyncNotifier (AsyncValue) を使って無限スクロールを実装してみます。
結論
ある程度の基本的な操作ならAsyncValue + AsyncNotifier
で実装できる:
- 基本的な無限スクロール動作
- 指定されたページサイズごとにデータを取得して表示する
- リスト下端までスクロールすると次のページを読み込む
- Pull-to-Refresh
- エラー画面・0件画面の表示
- 検索キーワードが更新されたらリフレッシュ
では実際に作成したアプリを見ながら説明していきます
動作仕様
アプリの動作イメージ
TextFieldにキーワードを入力するとGitHub上のリポジトリを検索して一覧表示します。検索には GitHub REST API /search/repositories
を利用します。
画面と状態遷移
画面の種類と表示の条件を定めます
ResultListPage |
FirstLoadingPage |
ErrorPage |
EmptyResultPage |
---|---|---|---|
表示できるデータが1件以上あれば、エラーが発生しても表示し続ける | 表示可能なデータが不在 and 読み込み中 | 表示可能なデータが不在 and 読み込み中でない and エラーあり | 表示可能なデータが不在 and 読み込み中でない and エラーなし |
![]() |
![]() |
![]() |
![]() |
加えて ResultListPage
には以下のような複数の状態・表示があります
スクロールによる追加読み込み | Pull-to-Refresh | 検索キーワード変更 |
---|---|---|
![]() |
![]() |
![]() |
これらの仕様をもとに状態遷移図を書くと以下のようになります。
実装
利用するライブラリ
dependencies:
freezed_annotation: 2.4.1
hooks_riverpod: 2.3.6
json_annotation: 4.8.1
riverpod_annotation: 2.1.1
dev_dependencies:
build_runner: 2.4.6
freezed: 2.4.1
riverpod_generator: 2.2.3
検索機能
APIの呼び出しはRepository内部に隠蔽してUI側から利用します。
検索結果のレスポンス型はfreezedを利用していますが、本記事の趣旨ではないので説明は省略します。
@riverpod
SearchRepository searchRepository(SearchRepositoryRef ref) {
return SearchRepository(
ref.watch(otherDependencyProvider),
);
}
class SearchRepository {
Future<GithubRepositorySearchResult> search({
required String query,
required int page,
}) async {
// API呼び出しの実装は省略
return result;
}
}
検索キーワードの管理
StateProvider
で簡単に用意しておきます。キーワードを編集するフォームWidgetから状態が変更される想定です。
final searchQueryProvider = StateProvider((_) => 'linux');
検索状態のモデル定義
実際に利用するときはAsyncValue
でラップされます。無限スクロールを実装するため現在のリスト&次に読み込むべきページ番号は絶対に必要として、今回は検索キーワードの更新も考慮して値を記憶しておきます。
@freezed
class SearchResultState with _$SearchResultState {
const factory SearchResultState({
required String query,
required int page,
required int? nextPage,
required List<GithubRepository> list,
}) = _SearchResultState;
}
検索状態の管理
初期化方法の指定
AsyncNotifier
を利用しますが、riverpod_generatorで自動生成するためFuture
型を返すbuild
関数を指定します。検索キーワードを監視しておき、更新されたらbuild
関数が再度呼ばれ検索状態も自動で更新されます。
build関数内のwatch
watchで監視する依存が更新されるとprovierの状態が再生成され、従来のProvider((ref){})
でwatchを使う場合と似たような動作となります。
@riverpod
class SearchResult extends _$SearchResult {
SearchRepository get _repository => ref.read(searchRepositoryProvider);
@override
Future<SearchResultState> build() async {
final query = ref.watch(searchQueryProvider);
final result = await _repository.search(query: query, page: 1);
return SearchResultState(
query: query,
page: 1,
nextPage: result.nextPage,
list: result.repositories,
);
}
}
追加読み込みの実装
Future<void> loadMore() async {
final previous = state;
if (previous.isLoading || !previous.hasValue) {
// 既にローディング中、表示データ不在の場合はスキップ
return;
}
final value = previous.requireValue;
final page = value.nextPage;
if (page == null) {
// 次のpage不在の場合はスキップ
return;
}
final query = value.query;
if (query != ref.read(searchQueryProvider)) {
// クエリ変更直後の検索に失敗した状態ならスキップ
return;
}
// 読み込み中の状態
state = const AsyncLoading<SearchResultState>().copyWithPrevious(previous);
// 追加読み込みを実行 + エラーハンドリング
final next = await AsyncValue.guard(() async {
final result = await _repository.search(
query: value.query,
page: page,
);
return value.copyWith(
page: page,
nextPage: result.nextPage,
list: [
...value.list,
...result.repositories,
],
);
});
// 失敗しても以前のデータをそのまま表示する
state = next.copyWithPrevious(previous);
}
hasValue, requireValue
今回の動作仕様を実現するためAsyncLoading, AsyncError
でもhasValue = true
となる状態が有り得るため、is AsyncData
では判定していません。
AsyncValue#copyWithPrevious
で前の状態を考慮しつつ次の状態を生成するのがポイントです。loadMore
呼び出し前の状態・検索処理成功の可否に応じて次の表のように状態が変化します。
呼び出し前 | 検索の実行中 | 完了後(成功) | 完了後(失敗) |
---|---|---|---|
AsyncLoading | - (検索をスキップ) |
- | - |
AsyncData | AsyncData (isLoading = true) |
AsyncData | AsyncError (hasValue = true) |
AsyncError (hasValue = true) |
AsyncError (isLoading = true, hasValue = true) |
AsyncData | AsyncError (hasValue = true) |
UIの実装
AsyncValue
でラップされた状態に応じて適切なWidgetを出し分けます
class SearchScreen extends ConsumerWidget {
const SearchScreen();
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(searchResultProvider);
if (state.hasValue && state.requireValue.list.isNotEmpty) {
// 表示できるデータが1件以上存在する
return const ResultListPage();
} else if (state.isLoading) {
// データ不在 && 読み込み中
return const FirstLoadingPage();
} else if (state.hasError) {
// データ不在 && 読み込み中でない && 直近の操作でエラーあり
return const ErrorPage();
} else {
// データ不在 && 読み込み中でない && エラーなし
return const EmptyResultPage();
}
}
}
map, when
AsyncValue
にはAsyncLoading/Data/Error
に応じて状態を変換する関数map,when
などが用意されています。しかし今回扱う状態と画面はAsyncLoading/Data/Error
の3つでは上手く対応できないため、自前で条件分岐を定義しています。
初回読み込み・0件画面の実装
statelessな画面です。特に実装上の難しさは有りません。よしなに実装してください。
class FirstLoadingPage extends StatelessWidget {
// ... your implementation ...
}
class EmptyResultPage extends StatelessWidget {
// ... your implementation ...
}
エラー画面の実装
再試行のボタンを用意します。ボタン押下でAsyncNotifier
のbuild
関数が再度実行されます。
class ErrorPage extends ConsumerWidget {
const ErrorPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 80,
),
const Text('An error happened'),
ElevatedButton(
onPressed: () {
ref.invalidate(searchResultProvider);
},
child: const Text('Retry'),
),
],
);
}
}
invalidate, refresh
providerの状態を再生成します。再生成後の新しい状態が不要であれば、返り値無しのinvalidate
を使います。また後述のPull-to-Refreshのように再生成の完了を待機したい場合は、ref.refresh(asyncNotifierProvider.future)
で Future 型を使えます。
invalidate, refresh
関数を呼び出すと以下のように状態が変化します。追加読み込みで使用したcopyWithPrevious
同様に前の状態を考慮してくれます
呼び出し前 | 再生成中 | 完了後(成功) | 完了後(失敗) |
---|---|---|---|
AsyncLoading | - (呼び出しの動線なし) |
- | - |
AsyncData | AsyncData (isLoading = true) |
AsyncData | AsyncError (hasValue = true) |
AsyncError (hasValue = _ ) |
AsyncError (hasValue = _ , isLoading = true) |
AsyncData | AsyncError (hasValue = _ ) |
エラー画面ではAsyncError(hasValue=false)
のみ想定されますが、後述のPull-to-Refreshでrefresh
関数を使う場合もこの表の通りです。
検索結果リスト画面の実装
検索結果を一覧表示する画面です。データが1件以上あれば、
- 直前の検索操作でエラー発生
- 何らかの検索処理を実行中
- それ以外のidle状態
どの状態でもこの画面で表示します。
class ResultListPage extends HookConsumerWidget {
const ResultListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(searchResultProvider);
final value = state.requireValue;
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels == n.metrics.maxScrollExtent) {
// 下端までスクロールしたら追加読み込みを実行
ref.read(searchResultProvider.notifier).loadMore();
}
return true;
},
child: RefreshIndicator(
// Pull-to-Refreshでproviderの状態を再生成する
onRefresh: () => ref.refresh(searchResultProvider.future),
child: Stack(
children: [
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(_, idx) {
final repo = value.list[idx];
return RepositoryItem(repo: repo);
},
childCount: value.list.length,
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 40,
child: Visibility(
/* 追加読み込み中の表示
Warning: pull-to-refreshでも表示される
十分なページサイズを指定していれば、リストの上端までスクロールしている状態では
下端のindicatorは見えないので大きな問題にはならない */
visible: value.nextPage != null && state.isRefreshing,
child: const Center(
child: CircularProgressIndicator(),
),
),
),
)
],
),
if (state.isReloading)
// キーワード変更による検索中
const Center(
child: CircularProgressIndicator(),
),
],
),
),
);
}
}
追加読み込み中の判定
copyWithPrevious
のオプショナル引数 bool isRefresh = true
を省略して実装したため、追加読み込み中の状態は refresh, invalidate
関数を使った検索再試行中&Pull-to-Refresh中の状態と一致し区別する方法がありません。
isReloading
build
関数でwatchしている依存が更新され、providerの状態が再生成中の場合にtrueとなります。refresh, invalidate
関数による外部からの操作と異なり、再生成中はAsyncLoading
で isRefreshing = false
です。
watchで監視している依存が更新された場合の状態変化は以下の通り。
再生成前 | 再生成中 | 完了後(成功) | 完了後(失敗) |
---|---|---|---|
AsyncLoading | AsyncLoading | AsyncData | AsyncError |
AsyncData | AsyncLoading (hasValue = true) |
AsyncData | AsyncError (hasValue = true) |
AsyncError (hasValue = _ ) |
AsyncLoading (hasValue = _ , hasError = true) |
AsyncData | AsyncError (hasValue = _ ) |
TODO
AsyncValue + AsyncNotifier
だけでは限界もあります
- 追加読み込み・Pull-to-Refreshの状態を区別できない
-
ResultListPage
で発生したエラーを表示するには追加で実装が必要 - etc.
自前で状態クラスを作成して管理する、もしくは専用のライブラリの使用をおすすめします