3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-06-14

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

前提

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

ひとまず作ってみたもの

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

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

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

      if (index < _searchResults.length) {
        return _buildItem(_searchResults[index]);
      }
      ...
      return const Center(child: const CircularProgressIndicator());

itemBuilder からアイテムを返すのを打ち切る必要がありますが、ググると itemBuilder から null を返せばよいという情報が見つかります。しかし、itemBuilder の型は

typedef IndexedWidgetBuilder = Widget Function(BuildContext context, int index);

なので、null を返せません。Flutter が Null Safty になったことで仕様が変わったのでしょうか? もう少し調べてみると、IndexedWidgetBuilder の他に NullableIndexedWidgetBuilder というのがあり、これを使うには ListView.builder ではなく ListView.custom で ListView を作れば良いことがわかりました。

ListView.custom に変更したコード

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

  ...
  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;
        })
    );
  }
  ...

これでプログレスインジケーターが1つだけ表示されるようになりました。
flutter_listview_2.gif

検索結果の終端までスクロールしたときにプログレスインジケーターが残ったまま

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

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の検索結果をスクロールした状態で検索ワードを変更すると、次のエラーが発生しました。

======== 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 の中で表示するデータを保持しているリストをクリアすればいいと考えたのですが、

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

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

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

...
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,  // ← これを追加
        ...

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

最終的なソースコード

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

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;
      });
    });
  }
}
3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?