Flutterで無限スクロールをScrollContollerを使って実装する際のTips

# はじめに
Flutterで無限スクロールを実装する際のTipsというタイトルにしましたが、`ScrollController`を用いてリストを監視したい時に広く使えるTipsです。
ハマり所でもあると思いますので、本記事を書きました。

`ListView`　/ `SliverList` / `SingleChildScrollView` などで適用できます。

![motinoring_listview.gif](https://qiita-image-store.s3.amazonaws.com/0/106237/7c552f6c-c592-f60d-c84f-97208322133d.gif)


サンプル
https://github.com/HeavenOSK/monitoring_scroll

# 実装方法
実装する際のポイントは以下の2点です。

1. `ListView`の`ScrollController`に対して、現在の表示位置を監視する`Listner`を追加する
2. iOS風の「AppBar上部をタップすると、ListViewを初期位置まで戻す」操作を実現するために、`Scaffold`を提供するWidgetと`ListView`を提供するWidgetを分離し、親子構造にする

#### 1について
現在の表示位置を監視して、下端に達した時に新たなコンテンツを追加する処理を行います。

#### 2について
Flutterでは、iOS風の「AppBar上部をタップすると、ListViewを初期位置まで戻す」操作（以下,**プライマリ操作**）がサポートされています。
しかし、`ScrollContoller`で`ListView`を監視する処理を素直に実装すると、**プライマリ操作**が無効になります。
> 例）
> https://stackoverflow.com/questions/46377779/flutter-implementing-a-infinitescroll-view

以下では、一旦iOS風の**プライマリ操作**のことを考慮せずに実装した例を載せたのち、なぜ**プライマリ操作**が無効化されたかを踏まえた実装の例を載せます。


## ListViewを監視する
実装順序は以下のようになります。

1.　`ScrollController`を初期化する
2.　`ScrollController`に`Listner`を追加して、更新処理を実装する
3.　作成した`ScrollController`を、監視対象のリストの`controller`プロパティに設定する

リストの監視は`ScrollController`を介して行います。
`ScrollController`には、`position`プロパティがあり、以下の値が取得できます。

- `position.maxScrollExtent` => ListView全体の下端位置
- `position.pixels` => 現在の表示位置

これらの値を比較して、「下端位置から20pixelの位置に達したら、コンテンツを読み込む」というような処理を追加します。

コードは以下のようになります。

`StatefulWidget`の`initState()`内で、`ScrollController`を初期化して、`addListner()`します。

```my_home_page.dart
  ScrollController _scrollController;

  void initState() {
    _scrollController = ScrollController();
    _scrollController.addListener(() {
      final maxScrollExtent = _scrollController.position.maxScrollExtent;
      final currentPosition = _scrollController.position.pixels;
      if (maxScrollExtent > 0 &&
          (maxScrollExtent - 20.0) <= currentPosition) {
        _addContents();
      }
    });
    super.initState();
  }
```

上記で作成した`_scrollController `を`ListView`の`controller`プロパティに設定します。

```my_home_page.dart
ListView.builder(
  controller: _scrollController,
  itemBuilder: (context, index) {
    return _buildListItem(_items[index]);
  },
  itemCount: _items.length,
),
```

ちなみに、コンテンツの追加処理は以下のようにしています。
今回はコードを単純にするために`setState()`で更新処理を行っています。

```my_home_page.dart
bool _isLoading = false;

_addContents() {
  if (_isLoading) {
    return;
  }
  _isLoading = true;
  Future.delayed(Duration(seconds: 2), () {
    setState(() {
      Contents.forEach((content) => _items.add(content));
    });
    _isLoading = false;
  });
}
```

`ScrollController`を用いてリストを監視する実装方法については以上です。

コード全体は以下に載せています。
https://github.com/HeavenOSK/monitoring_scroll/tree/008be42e2cb61352a0dda7ad71c0b74220fd0bdc/lib

この状態では、**プライマリ操作**は無効になってしまっています。
以下では、なぜ**プライマリ操作**が無効になったのかを説明します。

## なぜプライマリ操作が無効になったのか
Flutterの**プライマリ操作**は、`Scaffold`内で実装されています。
`Scaffold`は`appBar`上部のTap操作を監視しており、`appBar`上部がTapされた際には内部の`_primaryScrollController`という`ScrollController`を介してリストを初期位置に戻す操作を行います。

`Scaffold`の`body`にて`ListView`を実装する際、`ListView`の`controller`プロパティや`primary`プロパティを指定しなければ、暗黙的に`Scaffold`内の`_primaryScrollController`が適用されます。

つまり、上記のリストを監視する実装方法では、`Scaffold`内の`primaryScrollController`ではなく、独自に定義した`_scrollController`を`ListView`に適用しているため、**プライマリ操作**が無効になってしまいました。

以下では、**プライマリ操作**を有効にする実装方法を説明します。

## ListViewのプライマリ操作を有効にする
上記の通り、**プライマリ操作**を有効にするには、`ListView`に`Scaffold`内の`_primaryScrollController`を適用する必要があります。

コードに以下の修正を加えます。

1. `Scaffold`を提供するWidgetと`ListView`を提供するWidgetを分離して、親子構造にする
2. `ListView`を提供するWidget内にて、`PrimaryScrollController.of(context)`を用いて`Scaffold`内の`_primaryScrollController`を呼び出し、`addListner()`を実装する


#### 1.Scaffoldを提供するWidgetとListViewを提供するWidgetを分離して、親子構造にする
PrimaryScrollController.of(context)を使用するためには、呼び出し時に既にScaffoldがWidget Tree内に存在している必要があります。
`Scaffold`を提供するWidgetと`ListView`を提供するWidgetを分離して、親子構造にすることで、`ListView`の生成前には`Scaffold`の`build()`が完了し、Widget Tree内に存在している状態を作ります。

> (Widget Treeについてご興味がある方は、以下を参考にして下さい)
> Flutter の Widget ツリーの裏側で起こっていること by @_mono
> https://link.medium.com/KbOOF3GsRT
> 
> Introduction to widgets
> https://flutter.io/docs/development/ui/widgets-intro
>
> Widget inspector
> https://flutter.io/docs/development/tools/inspector

以下では、`MyHomePage`内では`ListView`の生成は行わずに、`MyHomePageList`内で行うようにしています。

`Scaffold`を提供するWidgetは以下のようになります。

```my_home_page.dart
class MyHomePage extends StatelessWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: MyHomePageList(),
    );
  }
}
```

`MyHomePageList`の`build()`は以下のようになります。

```my_home_page_list.dart
class _MyHomePageListState extends State<MyHomePageList> {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemBuilder: (context, index) {
        return _buildListItem(_items[index]);
      },
      itemCount: _items.length,
    );
  }
}

```

#### 2.ListViewを提供するWidget内にて、PrimaryScrollController.of(context)を用いてScaffold内の_primaryScrollControllerを呼び出し、addListner()を実装する
`PrimaryScrollController.of(context)`を用いて`Scaffold`内の`_primaryScrollController`を呼び出します。
`initState()`内では、`context`を使った処理は行えなえません。
そのため、以下では`didChangeDependencies()`で`Scaffold`内の`_primaryScrollController`の取得処理を行っています。
取得した`_primaryScrollController`に対して、前述した`ListView`を監視する処理を`addListener()`で追加しています。

```my_home_page_list.dart
ScrollController _primaryScrollController;
  
@override
void didChangeDependencies() {
  _primaryScrollController = PrimaryScrollController.of(context);
  _primaryScrollController.addListener(() {
    final maxScrollExtent = _primaryScrollController.position.maxScrollExtent;
    final currentPosition = _primaryScrollController.position.pixels;
    if (maxScrollExtent > 0 && (maxScrollExtent - 20.0) <= currentPosition) {
      _addContents();
    }
  });
  super.didChangeDependencies();
}

```
`ListView`の`controller`プロパティに対して、`_primaryScrollController`を設定します。

```my_home_page_list.dart
@override
Widget build(BuildContext context) {
  return ListView.builder(
    controller: _primaryScrollController,
    itemBuilder: (context, index) {
      return _buildListItem(_items[index]);
    },
    itemCount: _items.length,
  );
}
```

`ListView`のプライマリ操作を有効にする実装については以上です。
コード全体は以下に載せています。

https://github.com/HeavenOSK/monitoring_scroll


## まとめ
Flutterで無限スクロールを実装する際のTipsは以上です。

分かりにくい箇所や不明点などございましたら、私のTwitterアカウントまでお願いします。

https://twitter.com/heavenOSK/