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;
});
});
}
}
これを実行すると、プログレスインジケーターが沢山表示されてしまいました。
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つだけ表示されるようになりました。
検索結果の終端までスクロールしたときにプログレスインジケーターが残ったまま
検索結果の終端までスクロールしたときにプログレスインジケーターが残ったままになっているので、これを直すために次の修正をしました。_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;
});
});
}
}