🔖

AsyncValueを使いつつ体験を損なわない無限スクロールリストを実装する

2024/12/20に公開2

レスポンスを取得してリスト表示する

本記事はFlutterAdventCalendar2024の12/12に投稿されるはずだった記事です。筆者の体調不良で投稿が遅れてしまいました。🙇🏻‍♂️

Linc'wellでFlutterアプリ開発をしているNumaTatsuです。

アプリを実装する際、APIから取得したデータをリスト表示するケースは多いと思います。
その実装方法はレスポンス取得と画面描画を非同期で行う必要があります。

RiverpodのAsyncValueは、非同期処理を簡潔に書くことが出来ます。

クリフォアアプリにももちろん、リスト表示の画面があり、AsyncValueを使って非同期でのリスト表示を実現しています。

また、Riverpodの公式DocでもSampleユースケースとして紹介されています。
https://riverpod.dev/docs/essentials/first_request#rendering-the-network-requests-response-in-the-ui

Sample1

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(provider);
    return state.when(
            data: (state) {
              return ListView.builder(
                  itemCount: state.items.length,
                  itemBuilder: (context, index) {
                    final item = state.items[index];
                    return Text(item.title),
                  },
                ),
            },
            error: (error, stackTrace) {
              return const Text('エラーが発生しました'),
            },
            loading: () => const Center(child: CircularProgressIndicator()),
          );
  }
}

上記の例では、AsyncValuestatewhenで分岐しています。UI層はStateの変更を監視して描画を行います。
基本的にはこの形でリストを描画することが出来ます。

ページングを考慮するリストの描画

レスポンスの量が膨大になる場合、ページングを考慮する必要があります。
スマホアプリの場合、スクロールで次のページを取得する無限スクロールUIで実装することが多いと思います。

AsyncValueを使った、上記のようなBuild関数直下での実装でも、もちろん実現は可能です。
クリフォアアプリではScrollNotificationClassでスクロールを検知し,
ユーザーがリスト最下部に到達したタイミングで次のページを取得しています。

本記事ではリスト表示のSampleソースとして、FlutterリポジトリのIssue一覧を使って追加読み込みのあるリスト表示実装例を紹介します。
https://github.com/flutter/flutter/issues

Issue一覧を取得するSample

Sample1と同じような実装方法でIssue一覧を取得する例を作ります。

Sample2 Model・ViewModel

Sample2 Model・ViewModel
/// Model

class Issue with _$Issue {
  const factory Issue({
    required String title,
    required DateTime created_at,
  }) = _Issue;

  const Issue._();

  factory Issue.fromJson(Map<String, dynamic> json) => _$IssueFromJson(json);
}

/// ViewModel

class IssueListViewModel extends _$IssueListViewModel {
  IssueRepository get _repository => ref.read(issueRepositoryProvider);

  
  FutureOr<IssueListState> build() async {
    return IssueListState(issues: await _fetchIssues(1));
  }
    // Issue一覧を取得
  Future<List<Issue>> _fetchIssues(int page) async {
    final newIssues = await _repository.fetchIssues(page);
    return newIssues;
  }

    // Issueの追加読み込み
  Future<void> fetchMoreIssues() async {
    if (state.hasValue) {
      // hasMoreがfalseの場合は何もしない
      if (!state.value!.hasMore) {
        return;
      }
      // AsyncValueをロードを中にする
      state = const AsyncLoading();
      // 追加読み込み用の実装 (割愛)
      // ここでは、取得したIssueを末尾に追加して新しいstateを構築し、AsyncValueを更新しています。
      state = AsyncData(newState);
    }
  }
}

Sample2 Page

Sample2 Page

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final issueListState = ref.watch(issueListViewModelProvider);
    final scrollController = ScrollController();
    final state = ref.watch(provider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Issues'),
      ),
      body: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification scrollNotification) {
          if (scrollNotification is ScrollEndNotification) {
            // リストの最下部に到達した事を検知する
            final metrics = scrollNotification.metrics;
            if (metrics.extentAfter == 0) {
                // 追加の読み込みを行う
              ref.read(issueListViewModelProvider.notifier).fetchMoreIssues();
            }
            return true;
          }
          return false;
        },
        // AsyncValueの状態によって描画を変える
        child: issueListState.when(
          data: (issuesState) {
            return Scrollbar(
              controller: scrollController,
              thumbVisibility: true,
              // Issue一覧を描画
              child: ListView.builder(
                shrinkWrap: true,
                controller: scrollController,
                itemCount: issuesState.issues.length,
                itemBuilder: (context, index) {
                  final issue = issuesState.issues[index];
                  return Column(
                    // IssueItemを表示します(割愛)
                  );
                },
              ),
            );
          },
          error: (error, stackTrace) {
            return Center(
                // エラー表示の実装(割愛)
            );
          },
          // ローディング表示
          loading: () => const Center(child: CircularProgressIndicator()),
        ),
      ),
    );
  }
}

ユーザー体験の観点から実装を考える

上記の実装例で無限スクロールを実装した場合のキャプチャを紹介します。

ユーザー体験ベースでどのような課題が発生していかを考えてみます。

State変更の度に描画済みのIssueが消えて、ローディングインジケータが表示される

追加読み込み中は、それをユーザーに示すためにローディングインジケータを描画しています。
ですが、当然その間は読み込み済みのIssueを確認することが出来ません。

AsyncValueで用意されているupdate method を使ってStateを更新する場合はStateがローディング中にならずに済みますが、
レスポンスの返却に時間がかかった場合、リストに何も変化がないままの状態が続くため、ユーザー体験がいいとは言えません。

追加読み込み中にAsyncErrorが発生した場合、取得済みのIssueが消えてしまう

3ページ目のIssueを取得中にエラーが発生した場合の動画キャプチャを紹介します。

残念ながらエラーが発生したタイミングで、それまで取得出来ていたIssueのリストを確認出来なくなっています。

追加読み込み完了後、スクロール位置がリセットされる

再生成したIssueリスト使って、そのままリビルドを行っているためスクロール位置がリセットされてしまいます。
これでは毎回リストの最上部からスクロールし直す必要があり、ユーザー体験を損なっていると言えます。

どうすればユーザー体験を損なわないかを考える

上記の問題を解決するため、今のPageの描画構造を整理してみましょう。

Buildメソッドで返却するWidgetが直接AsyncValueのStateを監視しています。この構造では課題を解決出来ません。

まずはBuildメソッドの外でStateを監視し、AsyncDataになった時だけissueのリストをStateから取得するようにします。
maybeWhenメソッドを使うことで宣言的にStateを監視することが可能で、そこからIssueのリストを取得します。

    final issueListState = ref.watch(issueListViewModelProvider);
    final issues = issueListState.maybeWhen(
      data: (issuesState) => issuesState.issues,
      orElse: () => issueListState.valueOrNull?.issues ?? [],
    );

AsyncData以外の状態、つまりローディングやエラーの場合は取得済みのIssueリストがStateに保持されているのでそれを取得するようにしています。

描画したIssueListを保持する

Stateから取得したIssueItemをそのまま描画に使ってしまうと課題であるStateの変更による描画のリセットが発生してしまいます。
なので List<Widget> の変数を別に用意し、Buildメソッドではこの変数をリスト用のWidgetとして返却するように変更します。

    List<Widget> listItems = [
      ...issues.map(
        (issue) => IssueItem(issue: issue),
      ),
    ];

    // ~省略~

    Widget build(BuildContext context, WidgetRef ref) {
        return ListView(
            children: listItems,
            );
        }

これでIssueのリストであるissuesが更新されても、描画済みのIssueItemsが確保し続けられるようになりました。
また、追加読み込み中にエラーが発生した場合も、取得済みのIssueItemsが消えることはありません。

ローディング中のUI

ユーザーがリスト最下部に到達し、追加読み込みを行っている場合に出すローディングインジケータの実装を考えます。
その間、AsyncaValueのStateはAsyncLoadingになります。IssueItemsの最下部にローディングインジケータ用のUIを追加することで読み込み済みのIssueリストの表示を維持しつつ、ユーザーに追加読み込み中であること伝えることが出来ます。

    List<Widget> listItems = [
      ...issues.map(
        (issue) => IssueItem(issue: issue),
      ),
      // ローディング中はインジケータを表示
      if (issueListState.maybeWhen(loading: () => true, orElse: () => false))
        const Center(
          child: CircularProgressIndicator(),
        ),
    ];

エラーが発生した時のUI

追加読み込み中にエラーが発生した場合にユーザー体験を損なわないように配慮しつつ、ユーザーに伝えるUIを考えます。
ダイアログを表示する事も出来ますが、サンプルアプリではSnackBarを使ってエラーを通知するようにしています。

    
    // エラー発生をref.listenで検知してSnackBarで通知する
    ref.listen<AsyncValue<IssueListState>>(
      issueListViewModelProvider,
      (previous, next) {
        if (next.hasError) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Issueの取得に失敗しました')),
          );
        }
      },
    );

maybeWhenメソッドでもエラー発生を検知することが出来ますが、WidgetsBinding.instance.addPostFrameCallbackを使ってBuild後であることを保証しなければならないため、実装が複雑になるので自分的には推奨していません。

修正後のPage構造図

それぞれの課題解決をした後のPageの構造図は下記のようになります。

最終的なPageの実装例を下記に紹介します。

PageClass ソースコード全文
lib/ui/page/issue_list_page.dart

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen<AsyncValue<IssueListState>>(
      issueListViewModelProvider,
      (previous, next) {
        if (next.hasError) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Issueの取得に失敗しました')),
          );
        }
      },
    );
    final issueListState = ref.watch(issueListViewModelProvider);
    final issues = issueListState.maybeWhen(
      data: (issuesState) => issuesState.issues,
      orElse: () => issueListState.valueOrNull?.issues ?? [],
    );
    // リストで描画に使うWidgetの配列
    List<Widget> listItems = [
      ...issues.map(
        (issue) => IssueItem(issue: issue),
      ),
      // ローディング中はインジケータを表示
      if (issueListState.maybeWhen(loading: () => true, orElse: () => false))
        const Center(
          child: CircularProgressIndicator(),
        ),
    ];
    final scrollController = ScrollController();

    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Issues'),
      ),
      body: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification scrollNotification) {
          if (scrollNotification is ScrollEndNotification) {
            final metrics = scrollNotification.metrics;
            if (metrics.extentAfter == 0) {
              ref.read(issueListViewModelProvider.notifier).fetchMoreIssues();
            }
            return true;
          }
          return false;
        },
        child: Scrollbar(
          controller: scrollController,
          thumbVisibility: true,
          child: ListView(
            controller: scrollController,
            children: listItems,
          ),
        ),
      ),
    );
  }
}

class IssueItem extends StatelessWidget {
  const IssueItem({required this.issue, super.key});
  final Issue issue;

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Divider(
          color: Colors.grey,
          thickness: 1,
        ),
        ListTile(
          title: Text(issue.title),
          subtitle: Text(
            'created_at : ${issue.created_at.toLocal()}',
            softWrap: true,
            maxLines: 3,
            overflow: TextOverflow.ellipsis,
          ),
        ),
      ],
    );
  }
}

最後に修正後の動作のキャプチャを紹介します。

追加読み込み時にエラーが発生したパターンのキャプチャも紹介します。

今回はIssue一覧の取得を例にして、AsyncValueを使いつつ、ユーザー体験を損なわない無限スクロールリストの実装方法を紹介しました。
AsyncValueのwhenメソッドは直感的に実装が出来るため、非同期処理を簡潔に書くことが出来ます。
一方でViewの実装をする場合、State変更検知と画面の再Buildを考慮しないとユーザー体験を損なう可能性があるため、注意が必要です。

レスポンスの取得とリスト表示をAsyncValureを使って実装する際の参考になれば幸いです。

参考

今回のサンプルアプリのソースコードは下記リポジトリで公開しています。

https://github.com/Nuu-mA/infinite_scrolling_list_sample/releases/tag/v1.1.1

Linc'well, inc.

Discussion

DiegoDiego

1つのAsyncValueで表現せずに、
Stateの値として、errorやloadingを表現した方が簡単でわかりやすいなーと感じたのですが、
AsyncValueだけで表現するメリットって何があるでしょうか?

AsyncValueで表現するとすれば、
複数のproviderを使うやり方もあるみたいです。
https://codewithandrea.com/articles/flutter-riverpod-pagination/

NumaTatsuNumaTatsu

コメントありがとうございます!
以下、自分としての考えになります。

複数のproviderを使うやり方

もちろん、設計によっては無限スクロールを複数のproviderを組み合わせる手法も有用だと思います。
今回はRiverpodのネットワークリクエストに習って、可能な限り少ないproviderでの実装例として紹介しています。

Stateの値として、errorやloadingを表現

確かに、Stateでerrorやloadingを持たせて表現する方法も良く使われると思います。
自分としての考えになりますが上記の表現をした場合、Stateの該当パラメータを手動で更新する必要が出てきます。AsyncValueを使った場合に以下の恩恵を受けることが出来ると考えます。

AsyncValueで管理する場合、guardメソッドを使うことでAsyncLoadingの状態へと自動で変更してくれます。try-catch構文を書かなくてもエラー時はStateをAsyncErrorとして処理できるので実装を簡素化することが出来ます。

Stateを更新する際はupdateメソッドを使うことで安全に更新することが出来ます。こちらに関しては別の方がアドカレで解説記事を出されています。
https://qiita.com/masssun/items/0de5f2e4582e07be822d


以上、自分が考えるAsyncValueを使った場合のメリットを紹介させて頂きました 🙇🏻‍♂️
参考になれば幸いです!