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


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

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

[作成したアプリのGithubのリポジトリ](https://github.com/Seo-4d696b75/flutter_infinite_scroll_list)

# 結論

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

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



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

# 動作仕様

## アプリの動作イメージ

TextFieldにキーワードを入力するとGitHub上のリポジトリを検索して一覧表示します。検索には [GitHub REST API `/search/repositories`](https://docs.github.com/ja/rest/search/search?apiVersion=2022-11-28#search-repositories)を利用します。

![infinite_scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/4c3368f6-706d-4e5b-1fa0-6997a6f24de5.gif)




## 画面と状態遷移

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

| `ResultListPage` | `FirstLoadingPage` | `ErrorPage` | `EmptyResultPage` |
|---|----|---|---|  
|表示できるデータが１件以上あれば、エラーが発生しても表示し続ける|表示可能なデータが不在 and 読み込み中|表示可能なデータが不在 and 読み込み中でない and エラーあり|表示可能なデータが不在 and 読み込み中でない and エラーなし|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/a3fc44e3-3872-8eaa-a35b-7a1846f97e3b.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/36d66365-bbf9-802e-5748-b6bd2ec2721a.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/a8e17446-9dc0-dd73-9637-7d6cc57d2579.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/451f7188-2021-6da1-c566-471dfa26fc7c.png)|

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

| スクロールによる追加読み込み | Pull-to-Refresh | 検索キーワード変更 | 
|---|----|----|
| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/5c2c4943-bb90-edfc-700c-2e69109da039.png)| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/8068ac89-1294-319e-0c59-04dedf5c788d.png)| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/d7eb5e97-d7fe-4084-fcf9-7918ec2392bb.png)|


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

```mermaid
stateDiagram-v2
    [*] --> FirstLoadingPage
    FirstLoadingPage --> FirstLoadingPage: query changed
    state isFirstSuccess <<choice>>
    FirstLoadingPage --> isFirstSuccess: success?
    state isEmpty <<choice>>
    isFirstSuccess  --> isEmpty: Yes\n empty list?
    isEmpty --> EmptyResultPage: Yes
    EmptyResultPage --> FirstLoadingPage: query changed
    isEmpty --> ResultListPage: No
    state ResultListPage {
      [*] --> idling
      idling --> loading: pull-to-refresh / scroll down / query changed
      state isSuccess <<choice>>
      loading --> isSuccess: empty list?
      isSuccess --> idling: No
    }
    isSuccess --> EmptyResultPage: Yes
    isFirstSuccess --> ErrorPage: No
    ErrorPage --> FirstLoadingPage: retry / query changed
    
    
```

# 実装

[完全なコードはGithubのリポジトリを参照してください](https://github.com/Seo-4d696b75/flutter_infinite_scroll_list)

## 利用するライブラリ

```yaml
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を利用していますが、本記事の趣旨ではないので説明は省略します。

```dart
@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から状態が変更される想定です。

```dart
final searchQueryProvider = StateProvider((_) => 'linux');
```

## 検索状態のモデル定義
実際に利用するときは`AsyncValue`でラップされます。無限スクロールを実装するため現在のリスト＆次に読み込むべきページ番号は絶対に必要として、今回は検索キーワードの更新も考慮して値を記憶しておきます。

```dart
@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`関数が再度呼ばれ検索状態も自動で更新されます。

:::note info
build関数内のwatch

watchで監視する依存が更新されるとprovierの状態が再生成され、従来の`Provider((ref){})`でwatchを使う場合と似たような動作となります。
:::

```dart
@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,
    );
  }
}
```

### 追加読み込みの実装



```dart
  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);
  }
```

:::note warn
hasValue, requireValue

今回の動作仕様を実現するため`AsyncLoading, AsyncError`でも`hasValue = true`となる状態が有り得るため、`is AsyncData`では判定していません。
:::

`AsyncValue#copyWithPrevious`で前の状態を考慮しつつ次の状態を生成するのがポイントです。`loadMore`呼び出し前の状態・検索処理成功の可否に応じて次の表のように状態が変化します。

| 呼び出し前 | 検索の実行中 | 完了後（成功） | 完了後（失敗) | 
|:---:|:---:|:---:|:---:|  
| AsyncLoading | - <br> （検索をスキップ） | - | - |
| AsyncData | AsyncData <br> (isLoading = true)| AsyncData | AsyncError <br> (hasValue = true) | 
| AsyncError <br> (hasValue = true) | AsyncError <br> (isLoading = true, <br> hasValue = true) | AsyncData | AsyncError <br> (hasValue = true) |

## UIの実装

`AsyncValue`でラップされた状態に応じて適切なWidgetを出し分けます


```dart
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) {
      // 表示できるデータが１件以上存在する
      return const ResultListPage();
    } else if (state.isLoading) {
      // データ不在 && 読み込み中
      return const FirstLoadingPage();
    } else if (state.hasError) {
      // データ不在 && 読み込み中でない && 直近の操作でエラーあり
      return const ErrorPage();
    } else {
      // データ不在 && 読み込み中でない && エラーなし
      return const EmptyResultPage();
    }
  }
}
```

:::note warn
map, when

`AsyncValue`には`AsyncLoading/Data/Error`に応じて状態を変換する関数`map,when`などが用意されています。しかし今回扱う状態と画面は`AsyncLoading/Data/Error`の３つでは上手く対応できないため、自前で条件分岐を定義しています。
:::

### 初回読み込み・０件画面の実装
statelessな画面です。特に実装上の難しさは有りません。よしなに実装してください。

```dart
class FirstLoadingPage extends StatelessWidget {
  // ... your implementation ...
}

class EmptyResultPage extends StatelessWidget {
  // ... your implementation ...
}
```

### エラー画面の実装

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


```dart
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'),
        ),
      ],
    );
  }
}
```

:::note info
invalidate, refresh

providerの状態を再生成します。再生成後の新しい状態が不要であれば、返り値無しの`invalidate`を使います。また後述のPull-to-Refreshのように再生成の完了を待機したい場合は、`ref.refresh(asyncNotifierProvider.future)`で Future 型を使えます。
:::

`invalidate, refresh`関数を呼び出すと以下のように状態が変化します。追加読み込みで使用した`copyWithPrevious`同様に前の状態を考慮してくれます :thumbsup: 


| 呼び出し前 | 再生成中 | 完了後（成功） | 完了後（失敗） |
|:---:|:----:|:-----:|:---:|
| AsyncLoading | - <br> (呼び出しの動線なし) | - | - |
| AsyncData |　AsyncData <br> (isLoading = true) | AsyncData | AsyncError <br> (hasValue = true) |  
| AsyncError <br> (hasValue = `_`) | AsyncError <br> (hasValue = `_`, <br> isLoading = true) | AsyncData |  AsyncError <br> (hasValue = `_`) |


エラー画面では`AsyncError(hasValue=false)`のみ想定されますが、後述のPull-to-Refreshで`refresh`関数を使う場合もこの表の通りです。

### 検索結果リスト画面の実装

検索結果を一覧表示する画面です。データが１件以上あれば、

- 直前の検索操作でエラー発生
- 何らかの検索処理を実行中
- それ以外のidle状態

どの状態でもこの画面で表示します。

```dart

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(),
              ),
          ],
        ),
      ),
    );
  }
}
```

:::note warn
追加読み込み中の判定

`copyWithPrevious`のオプショナル引数 `bool isRefresh = true`を省略して実装したため、追加読み込み中の状態は `refresh, invalidate`関数を使った検索再試行中＆Pull-to-Refresh中の状態と一致し区別する方法がありません。
:::


:::note info
isReloading

`build`関数でwatchしている依存が更新され、providerの状態が再生成中の場合にtrueとなります。`refresh, invalidate`関数による外部からの操作と異なり、再生成中は`AsyncLoading`で `isRefreshing = false`です。

:::


watchで監視している依存が更新された場合の状態変化は以下の通り。

| 再生成前 | 再生成中 | 完了後（成功） | 完了後（失敗） |
|:---:|:----:|:-----:|:---:|
| AsyncLoading | AsyncLoading | AsyncData | AsyncError |
| AsyncData |　AsyncLoading <br> (hasValue = true) | AsyncData | AsyncError <br> (hasValue = true) |  
| AsyncError <br> (hasValue = `_`) | AsyncLoading <br> (hasValue = `_`, <br> hasError = true) | AsyncData |  AsyncError <br> (hasValue = `_`) |


# TODO


`AsyncValue + AsyncNotifier`だけでは限界もあります

- 追加読み込み・Pull-to-Refreshの状態を区別できない
- `ResultListPage`で発生したエラーを表示するには追加で実装が必要
- etc.

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

https://pub.dev/packages/infinite_scroll_pagination