10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Flutter + riverpod で無限スクロールをどこまで作れるか?

Posted at

注意
riverpod: ^2.0.0 を前提とします

riverpod と AsyncNotifier (AsyncValue) を使って無限スクロールを実装してみます。

作成したアプリのGithubのリポジトリ

結論

ある程度の基本的な操作ならAsyncValue + AsyncNotifierで実装できる:

  • 基本的な無限スクロール動作
    • 指定されたページサイズごとにデータを取得して表示する
    • リスト下端までスクロールすると次のページを読み込む
  • Pull-to-Refresh
  • エラー画面・0件画面の表示
  • 検索キーワードが更新されたらリフレッシュ

では実際に作成したアプリを見ながら説明していきます

動作仕様

アプリの動作イメージ

TextFieldにキーワードを入力するとGitHub上のリポジトリを検索して一覧表示します。検索には GitHub REST API /search/repositoriesを利用します。

infinite_scroll.gif

画面と状態遷移

画面の種類と表示の条件を定めます

ResultListPage FirstLoadingPage ErrorPage EmptyResultPage
表示できるデータが1件以上あれば、エラーが発生しても表示し続ける 表示可能なデータが不在 and 読み込み中 表示可能なデータが不在 and 読み込み中でない and エラーあり 表示可能なデータが不在 and 読み込み中でない and エラーなし
image.png image.png image.png image.png

加えて ResultListPageには以下のような複数の状態・表示があります

スクロールによる追加読み込み Pull-to-Refresh 検索キーワード変更
image.png image.png image.png

これらの仕様をもとに状態遷移図を書くと以下のようになります。

実装

完全なコードはGithubのリポジトリを参照してください

利用するライブラリ

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 ...
}

エラー画面の実装

再試行のボタンを用意します。ボタン押下でAsyncNotifierbuild関数が再度実行されます。

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同様に前の状態を考慮してくれます :thumbsup:

呼び出し前 再生成中 完了後(成功) 完了後(失敗)
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関数による外部からの操作と異なり、再生成中はAsyncLoadingisRefreshing = 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.

自前で状態クラスを作成して管理する、もしくは専用のライブラリの使用をおすすめします

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?