Flutterで無限スクロールする手法3選

この記事は[Flutter #1 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/flutter)の4日目の記事です。
遅刻しました、すみません！
サンプルコードの用意が間に合わなかったのです…

# 無限スクロール

まずはGifを見てください。
リストのスクロールが下端に着くとAPI的には次のページが読み込まれる、利用者にページネーションを気にさせないページネーションの方法です。


![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41896/1a4b1f7f-3370-4ead-3846-7361103e7189.gif)



これをFlutterのListViewで実現しようとすると、実はいくつか方法があります。
私が今まで見てきた実装は主に3パターンに分類できるので、この記事ではその3パターンをご紹介します。

方法によってメリット/デメリットがあるため、用途に合わせて適切な方法を選ぶと良いと思います。


# 全体的な設計と想定環境

[StateNotifier](https://pub.dev/packages/state_notifier) + [Provider](https://pub.dev/packages/provider)による状態管理と状態通知を使用します。
状態を保持する値クラスの生成には[freezed](https://pub.dev/packages/freezed)を使用しました。



アーキテクチャはこんな感じです。

```
                     +----------+
                     |Repository|
                     +----------+
                         ↓ List<Item>とNextPageToken
                     +----------+
                     |Controller|
                     +----------+
   次ページ読み込みのシグナル↑    ↓StateNotifier+Provierで状態通知
                     +----------+
                     |    UI    |
                     +----------+
```


今回は実際のサーバーへ問い合わせをしないので、Repository層はダミーです。
実際はバックエンドサーバーへHTTPやgRPCで問い合わせをしたり、FirestoreのようなDBをlistenしたりするかと思います。

次ページがあるかどうかはトークンという形で管理しました。
べつにページ数を保持してもいいですし、リストの最後の要素のIDなどを保持してもいい想定です。

## サンプルコード

こちらに動作するコードを用意しています。
https://github.com/kikuchy/infinity_scrolling

# 手法

パターン名は勝手に付けました。
もっと一般的な名称とか、格好いい名称とかあればぜひ教えて下さい。

## builderのindexを見てリクエストを発行する方法（Last Index パターン）

[`ListView.builder()`](https://api.flutter.dev/flutter/widgets/ListView/ListView.builder.html)の `itemBuilder` の第二引数にはListViewが生成しようとしているリスト要素のインデックスが渡ってきます。
現在取得済みの要素数とインデックスが一致したときに次のページのデータをリクエストする方法です。

### 利点

何より実装が簡単で単純明快だということに尽きます。
テクニカルなことを要求されないので、どんな習熟度のメンバーがいるチームでも採用しやすいでしょう。

### 欠点

読込中に何度もリスト下端に到達する可能性があるので、読込中の状態を見てリクエストを飛ばすべきか否かを制御する必要があります。
制御を忘れるとリクエストが飛びまくったり、受信した要素が重複しまくったりするかもです。

また、リストがだいたい下端に着かないとリクエストが飛ばないため、「もっとリストの上の方でリクエストを飛ばして読み込みを始めたい」というときには少し調整が要ります。
特にリスト要素の高さや1ページあたりの要素数によって、作るリストごとにいつリクエストを飛ばすのかという調整が必要になります

同じようなことをするListView全てに個別にコードを書くことになるので、再利用性には乏しいかも？
（とはいえ大したコードではないので何度書いても問題ないかもしれません）


### 実装

最後の要素になったときに次ページを読めばOK.
レスポンスが早かったりすると、build中にsetStateを呼ぶな、とフレームワークに怒られるので、Futureでラップして次のイベントループに呼び出しを遅らせました。

[全コードはこちら](https://github.com/kikuchy/infinity_scrolling/blob/main/lib/pages/last_index_pattern.dart)

```dart
            final items = context.select((TimelineState state) => state.items);
            final hasNext =
                context.select((TimelineState state) => state.hasNext);
            return ListView.builder(
              itemBuilder: (context, i) {
                if (i == items.length - 1 && hasNext) {
                  // build中にsetStateするなと怒られるやつ対策
                  Future(context.read<TimelineController>().loadNext);
                }
                final item = items[i];
                return ListTile(title: Text(item.content));
              },
              itemCount: items.length,
            );
```


## ScrollControllerでスクロール量を判定する方法 (Scroll Positon パターン)

ListViewにはScrollControllerを仕込むことができ、ScrollControllerから現在のスクロール位置と最大スクロール可能サイズを知ることができます。
ここから、リストの高さ何％分だけスクロールしたのかを計算できるので、スクロール量が閾値を超えたらリクエストを飛ばす、ということが可能になります。

### 利点

ListView以外のScrollViewにも使用可能です！
他でも応用が効くので嬉しい限り。

また、リストの任意の位置で次ページの読み込みを開始できるため、UXへの配慮した調整も簡単です。

### 欠点

読込中に何度も閾値を越える可能性があるので、読込中の状態を見てリクエストを飛ばすべきか否かを制御する必要があります。
制御を忘れるとリクエストが飛びまくったり、受信した要素が重複しまくったりするかもです。

実装が少々面倒かもわかりません。
（が、下で紹介するようなスニペットがあれば簡単に実装できるのでそんなに面倒ではないかも）

### 実装

[全コードはこちら](https://github.com/kikuchy/infinity_scrolling/blob/main/lib/pages/scroll_position_pattern.dart)

まずはスクロール量を判定するくんを用意します。


```dart
class _ScrollDetector extends StatefulWidget {
  final Widget Function(BuildContext, ScrollController) builder;
  final VoidCallback loadNext;
  final double threshold;

  _ScrollDetector({
    @required this.builder,
    @required this.loadNext,
    @required this.threshold,
  });

  @override
  __ScrollDetectorState createState() => __ScrollDetectorState();
}

class __ScrollDetectorState extends State<_ScrollDetector> {
  ScrollController _controller;

  @override
  void initState() {
    super.initState();
    _controller = ScrollController()
      ..addListener(() {
        final scrollValue =
            _controller.offset / _controller.position.maxScrollExtent;
        if (scrollValue > widget.threshold) {
          widget.loadNext();
        }
      });
  }

  @override
  Widget build(BuildContext context) {
    return widget.builder(context, _controller);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}
```

それを使ってスクロール量がしきい値以上になったときのコールバックで次ページの読み込みを走らせます。
今回は80%スクロールされたら次ページを読むようにします。

```dart
            return _ScrollDetector(
              threshold: 0.8,
              loadNext: () {
                context.read<TimelineController>().loadNext();
              },
              builder: (context, scrollController) => ListView.builder(
                controller: scrollController,
                itemBuilder: (context, i) {
                  final item = items[i];
                  return ListTile(title: Text(item.content));
                },
                itemCount: items.length,
              ),
            );
```




## リスト下端のRenderObjectが画面内に入ったことを検知する方法 (In Sightパターン)

Flutterはいわゆる宣言的UIのフレームワークであり、Widgetはいわば仮想DOMみたいなもので、Widgetが直接画面に出る要素を管理しているかというとそうではありません。
実際のDOMにあたるRenderObjectが描画を司ります。
（WidgetとRenderObjectの関係については[こちらの記事](https://engineer.recruit-lifestyle.co.jp/techblog/2019-12-24-flutter-rendering/)がわかりやすいです）


[visibility_detector](https://pub.dev/packages/visibility_detector)のようにRenderObjectが画面上に表示されるとコールバックを叩いてくれるものがあるので、これを使ってリストの最後の要素が画面に出たことを検知して次ページをリクエストするという方法があります。

## 利点

リスト下端にLoadingIndicatorを表示するようなUXと相性が良いです。
こうしたデザインのリストであれば必ず最後に番兵としてLoadingIndicatorを挿入するので、それにvisibility_detectorをセットにしておけば良いわけです。

また、indexを用いないリストの生成方法（CustomScrollViewにSliverChildListDelegateなどで要素をまとめて放り込むとか）でも使用可能です。
（リストの特定の位置にだけ特定の要素を入れたいときなどに時々やったりする）

## 欠点

基本的にリストの下端が画面に出てこないとリクエストが飛ばないので、利用者を必ず待たせる瞬間が生まれます。
UX的にそれを許容できない場合は使用できない方法でしょう。

（SliverChildListDelegateで要素をまとめて放り込む際限定ですが、visibility_detectorを含んだLoadingIndicatorがアニメーションしていると、見えていなくても常にアニメーションを描画するリソースが消費される、という問題もあります。このパターンの直接の問題ではないですが…）

### 実装

[全コードはこちら](https://github.com/kikuchy/infinity_scrolling/blob/main/lib/pages/in_sight_pattern.dart)

リスト下端に出現する番兵を用意します。
今回は読み込み中であることがわかるようにIndicatorを設置。

```dart
class _LastIndicator extends StatelessWidget {
  final VoidCallback onVisible;
  _LastIndicator(this.onVisible);
  @override
  Widget build(BuildContext context) {
    return VisibilityDetector(
      key: Key("for detect visibility"),
      onVisibilityChanged: (info) {
        if (info.visibleFraction > 0.1) {
          onVisible();
        }
      },
      child: SizedBox(
        width: 20,
        height: 20,
        child: CircularProgressIndicator(),
      ),
    );
  }
}

```

リストの最後に番兵を出します。

```dart

            return ListView.builder(
              itemBuilder: (context, i) {
                if (i == items.length && hasNext) {
                  return _LastIndicator(() {
                    context.read<TimelineController>().loadNext();
                  });
                }
                final item = items[i];
                return ListTile(title: Text(item.content));
              },
              itemCount: items.length + 1,
            );

```


# まとめ

Flutterで無限スクロールを実現する方法を3パターンご紹介しました。

* builderのindexを見てリクエストを発行する方法（Last Index パターン）
* ScrollControllerでスクロール量を判定する方法 (Scroll Positon パターン)
* リスト下端のRenderObjectが画面内に入ったことを検知する方法 (In Sightパターン)

実装サンプルはこちら
https://github.com/kikuchy/infinity_scrolling

利用者のUXに影響するので、作成しているアプリの特性に合わせて適切な方法が選べると良いですね。