Flutterで無限ListViewをやろうとして躓いたこと

Flutterで無限ListViewをやろうとしていくつか躓いて調べたことのメモです。

## 前提
* 検索バーに入力したワードの検索結果をListViewに表示する
* 検索結果はListViewに表示される部分だけを必要に応じて非同期に取得
* 無限ListViewといっても検索結果の個数は有限
* 検索バーのワードを変更するとListViewはリセットされ、新しい検索結果を表示

## ひとまず作ってみたもの
Flutterで無限ListViewをやる方法をググると `ListView.builder` を使えばできるという情報が複数みつかるので、ListView.builder でやってみたのが以下のコードです。

```dart
import 'package:flutter/material.dart';
import 'package:material_floating_search_bar/material_floating_search_bar.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "ListView Example",
      home: Scaffold(body: MyHomePage()),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key}) : super(key: key);

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

class _MyHomePageState extends State<MyHomePage> {
  String _query = "";
  final _searchResults = <String>[];
  bool _loading = false;

  @override
  Widget build(BuildContext context) {
    return FloatingSearchAppBar(
      body: _buildListView(),
      onSubmitted: (query) {
        setState(() {
          _query = query;
          _searchResults.clear();
        });
      },
    );
  }

  Widget _buildListView() {
    return ListView.builder(itemBuilder: (context, index) {
      if (index < _searchResults.length) {
        return _buildItem(_searchResults[index]);
      }
      if (!_loading) {
        _fetchSearchResults(index, 10);
      }
      return const Center(child: const CircularProgressIndicator());
    });
  }

  Widget _buildItem(String searchResult) {
    return ListTile(title: Text(searchResult));
  }

  Future<void> _fetchSearchResults(int offset, int count) async {
    _loading = true;

    Future.delayed(Duration(seconds: 1), () {
      // 検索ワードによって結果の個数が変わるようにする。hashCode を使って適当に個数を決定。
      final max = _query.hashCode % 50 + 20;
      if (max < offset + count) {
        count = max - offset;
      }

      final _fetched = <String>[];
      for (int i = 0; i < count; ++i) {
        _fetched.add("${_query} ${offset + i + 1}");
      }

      setState(() {
        _searchResults.addAll(_fetched);
        _loading = false;
      });
    });
  }
}
```
これを実行すると、プログレスインジケーターが沢山表示されてしまいました。
![flutter_listview_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90023/c365ce86-662c-f457-5a4b-a0d513e245de.gif)

itemBuilder で _searchResults.length 以上のインデックスには全部 CircularProgressIndicator を返しているので当然ですね。

```dart
      if (index < _searchResults.length) {
        return _buildItem(_searchResults[index]);
      }
      ...
      return const Center(child: const CircularProgressIndicator());
```
itemBuilder からアイテムを返すのを打ち切る必要がありますが、ググると itemBuilder から null を返せばよいという情報が見つかります。しかし、itemBuilder の型は

```dart
typedef IndexedWidgetBuilder = Widget Function(BuildContext context, int index);
```
なので、null を返せません。Flutter が Null Safty になったことで仕様が変わったのでしょうか？　もう少し調べてみると、`IndexedWidgetBuilder` の他に `NullableIndexedWidgetBuilder` というのがあり、これを使うには `ListView.builder` ではなく `ListView.custom` で ListView を作れば良いことがわかりました。

## ListView.custom に変更したコード

先ほどのコードの `ListView.builder(...)` のところを次のようにしました。ついでに、検索ワードが空の時もすぐに打ち切るようにしました。

```dart
  ...
  Widget _buildListView() {
    return ListView.custom(
        childrenDelegate: SliverChildBuilderDelegate((context, index) {
          if (_query.isEmpty) {
            return null;
          }
          if (index < _searchResults.length) {
            return _buildItem(_searchResults[index]);
          }
          if (!_loading) {
            _fetchSearchResults(index, 10);
            return const Center(child: const CircularProgressIndicator());
          }
          return null;
        })
    );
  }
  ...
```

これでプログレスインジケーターが１つだけ表示されるようになりました。
![flutter_listview_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90023/fd1fb07e-135e-b1d7-9442-4d1715279e4e.gif)

## 検索結果の終端までスクロールしたときにプログレスインジケーターが残ったまま
検索結果の終端までスクロールしたときにプログレスインジケーターが残ったままになっているので、これを直すために次の修正をしました。_MyHomePageState クラスに _noMoreItems というインスタンス変数を追加して、それに関する修正を数カ所行っています。

```dart
class _MyHomePageState extends State<MyHomePage> {
  ...
  bool _noMoreItems = true;  // ← これを追加
  
  @override
  Widget build(BuildContext context) {
    ...
        setState(() {
          _query = query;
          _searchResults.clear();
          _noMoreItems = false;  // ← これを追加
        });
    ...
  }

  Widget _buildListView() {
    ...
          if (!_loading && !_noMoreItems) {  // ← ここを修正
            _fetchSearchResults(index, 10);
            return const Center(child: const CircularProgressIndicator());
          }
    ...
  }
  ...
  Future<void> _fetchSearchResults(int offset, int count) async {
     ...
      setState(() {
        _searchResults.addAll(_fetched);
        _loading = false;
        _noMoreItems = _fetched.isEmpty;  // ← これを追加
      });
    });
  }
```


## ListViewをスクロールした状態で検索ワードを変更するとエラーが発生
ListViewの検索結果をスクロールした状態で検索ワードを変更すると、次のエラーが発生しました。

```text
======== Exception caught by rendering library =====================================================
The following assertion was thrown during performLayout():
'package:flutter/src/rendering/sliver_list.dart': Failed assertion: line 186 pos 16: 'earliestUsefulChild != null': is not true.

（以下省略）
```

ListViewの内容をクリアするには setState の中で表示するデータを保持しているリストをクリアすればいいと考えたのですが、

```dart
  Widget build(BuildContext context) {
    ...
      onSubmitted: (query) {
        setState(() {
          _query = query;
          _searchResults.clear();  // ← これ
          _noMoreItems = false;
        });
      ...
```

どうやらそれだけではダメな感じです。Flutterの理解がまだ十分ではなくよくわかってないのですが、Widgetの `key` というやつが関係してそうです。

https://www.youtube.com/watch?v=kn0EOS-ZiIc

この動画を見て、ListViewの内容をクリアするにはListViewのkeyを変えてやればいいのでは？　と考えて次のように修正しました。

```dart
...
class _MyHomePageState extends State<MyHomePage> {
  ...
  Key? _listViewKey;  // ← これを追加

  @override
  Widget build(BuildContext context) {
    ...
      onSubmitted: (query) {
        setState(() {
          _query = query;
          _searchResults.clear();
          _noMoreItems = false;
          _listViewKey = UniqueKey();  // ← これを追加
        });
      ...
  }

  Widget _buildListView() {
    return ListView.custom(
        key: _listViewKey,  // ← これを追加
        ...
```

これで先ほどのエラーは出なくなりました。

## 最終的なソースコード

最後に、でできたソースコード全体を載せておきます。

```dart
import 'package:flutter/material.dart';
import 'package:material_floating_search_bar/material_floating_search_bar.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "ListView Example",
      home: Scaffold(body: MyHomePage()),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key}) : super(key: key);

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

class _MyHomePageState extends State<MyHomePage> {
  String _query = "";
  final _searchResults = <String>[];
  bool _loading = false;
  bool _noMoreItems = true;
  Key? _listViewKey;

  @override
  Widget build(BuildContext context) {
    return FloatingSearchAppBar(
      body: _buildListView(),
      onSubmitted: (query) {
        setState(() {
          _query = query;
          _searchResults.clear();
          _noMoreItems = false;
          _listViewKey = UniqueKey();
        });
      },
    );
  }

  Widget _buildListView() {
    return ListView.custom(
        key: _listViewKey,
        childrenDelegate: SliverChildBuilderDelegate((context, index) {
          if (_query.isEmpty) {
            return null;
          }
          if (index < _searchResults.length) {
            return _buildItem(_searchResults[index]);
          }
          if (!_loading && !_noMoreItems) {
            _fetchSearchResults(index, 10);
            return const Center(child: const CircularProgressIndicator());
          }
          return null;
        })
    );
  }

  Widget _buildItem(String searchResult) {
    return ListTile(title: Text(searchResult));
  }

  Future<void> _fetchSearchResults(int offset, int count) async {
    _loading = true;

    Future.delayed(Duration(seconds: 1), () {
      // 検索ワードによって結果の個数が変わるようにする。hashCode を使って適当に個数を決定。
      final max = _query.hashCode % 50 + 20;
      if (max < offset + count) {
        count = max - offset;
      }

      final _fetched = <String>[];
      for (int i = 0; i < count; ++i) {
        _fetched.add("${_query} ${offset + i + 1}");
      }

      setState(() {
        _searchResults.addAll(_fetched);
        _loading = false;
        _noMoreItems = _fetched.isEmpty;
      });
    });
  }
}
```