Flutter Advent Calendar 2023 7日目の記事です。
はじめに
どうもFlutterをしばらく使っていますが、ページング対応画面を実装するときのしっくり来る書き方が決まらないでいるK9iです。
今回VGV(Very Good Ventures)がvery_good_inifinite_listというパッケージを出していることを知ったので試してみます。
余談
VGVはFlutter界隈の超有名企業です。紹介記事書いてるので興味あったら。
キャッチアップ
Readme
Readmeを見たところListViewのかわりにInfiniteListというWidgetを使うみたいです👀
exampleを試してみる
新しいパッケージを試すので、とりあえず公式リポジトリをクローンして動かしてみます。
main.dartはいくつかのExampleページに遷移するためのページで、ここではvery_good_infinite_listは使われてないようです。
- Simple Example
- StatfulWidget内でInfiniteList使う基本の例 - Centralized Example
- InfiniteListでローディング・空表示・エラーを中央表示する例 - Advanced Example
- Blocパッケージと組み合わせる例 - Sliver Example
- Sliverの中で使うInfiniteListを使う例
Simple Example
クラス名こそ違いますが、Readmeに乗っていた例と同じような感じです。
separatorBuilderがある点などはListView.separatedと近いほか、isLoadingやonFetchDataなどページング対応画面を実装する上で便利な引数があります。
class SimpleExampleState extends State<SimpleExample> {
var _items = <String>[];
var _isLoading = false;
Future<void> _fetchData() async {
setState(() {
_isLoading = true;
});
await Future<void>.delayed(const Duration(seconds: 1));
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_items = List.generate(_items.length + 10, (i) => 'Item $i');
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Simple Example'),
),
body: InfiniteList(
itemCount: _items.length,
isLoading: _isLoading,
onFetchData: _fetchData,
separatorBuilder: (context, index) {
return Divider(
color: index.isOdd ? Colors.black : Colors.blue,
);
},
itemBuilder: (context, index) {
return ListTile(
dense: true,
title: Text(_items[index]),
);
},
),
);
}
}
InfiniteListの内部はCustomScrollViewのsliversに後述するSliverInfiniteListを渡していました。
@override
Widget build(BuildContext context) {
return CustomScrollView(
scrollDirection: scrollDirection,
reverse: reverse,
controller: scrollController,
physics: physics,
cacheExtent: cacheExtent,
slivers: [
_ContextualSliverPadding(
padding: padding,
scrollDirection: scrollDirection,
sliver: SliverInfiniteList(
Centralized Example
TabBarViewでローディング・空表示エラー・を中央表示するパターンを確認できるようになってます。
それぞれloadingBuilder、emptyBuilder、errorBuilderを指定した上で、centerLoadingなどをtrueにすれば良いようです。
class _CentralizedExamplesState extends State<CentralizedExamples>
with SingleTickerProviderStateMixin {
final tabs = const [
Tab(text: 'Loading'),
Tab(text: 'Empty'),
Tab(text: 'Error'),
];
late final tabBarController = TabController(length: 3, vsync: this);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Centralized Examples'),
bottom: TabBar(
tabs: tabs,
controller: tabBarController,
),
),
body: TabBarView(
controller: tabBarController,
children: [
_LoadingExample(),
_EmptyExample(),
_ErrorExample(),
],
),
);
}
}
Widget _buildItem(BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
);
}
class _LoadingExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InfiniteList(
itemCount: 0,
isLoading: true,
centerLoading: true,
loadingBuilder: (_) => const SizedBox(
height: 10,
width: 120,
child: LinearProgressIndicator(),
),
onFetchData: () async {},
itemBuilder: _buildItem,
);
}
}
class _EmptyExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InfiniteList(
itemCount: 0,
centerEmpty: true,
emptyBuilder: (_) => const Text(
'No items',
style: TextStyle(fontSize: 20),
),
onFetchData: () async {},
itemBuilder: _buildItem,
);
}
}
class _ErrorExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InfiniteList(
itemCount: 0,
hasError: true,
centerError: true,
errorBuilder: (_) => const Icon(
Icons.error,
size: 60,
color: Colors.red,
),
onFetchData: () async {},
itemBuilder: _buildItem,
);
}
}
centerLoadingを有効にするかどうかの差
内部的にはcenterLoadingなどがtrueだとSliverCentralizedで各builderをラップしてました。
ローディングに関してはすでにitemがあるときはcenterにならない配慮もありますね。
空表示はいいとして、エラーは2ページ目以降でエラーのときにitemを表示しつつ、末尾がエラー表示みたいなのには対応してなさそうです。Snackbarとかで対応するのがいいのかな?
if (widget.centerLoading && widget.isLoading && effectiveItemCount == 1) {
centeredSliver = SliverCentralized(child: loadingBuilder(context));
} else if (widget.centerError && widget.hasError) {
centeredSliver = SliverCentralized(child: errorBuilder(context));
} else if (widget.centerEmpty && showEmpty) {
centeredSliver = SliverCentralized(child: widget.emptyBuilder!(context));
}
SliverCentralizedはSliverFillRemainingとCenterを使ってます。
(せやろなと言った感じ)
class _SliverCentralizedState extends State<SliverCentralized> {
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: widget.child,
),
);
}
}
Advanced Example
日本でBloc使ってる人少なそうなのでこの記事では触れません。
Sliver Example
Sliverの中でInfiniteListを使いたいとき用にSliverInfiniteListというWidgetが用意されています。
前述してますがInfiniteListがSliverInfiniteListを内部で使っており、使い方も同じですね。
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBar(
expandedHeight: 400,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text('Sliver Example'),
),
),
SliverInfiniteList(
itemCount: _items.length,
isLoading: _isLoading,
onFetchData: _fetchData,
separatorBuilder: (context, _) => const Divider(),
itemBuilder: (context, index) {
return ListTile(
dense: true,
title: Text(_items[index]),
);
},
),
],
),
);
}
}
onFetchDataの仕組み
次のページを読み込むときに呼ばれるonFetchData周りの仕組みを調べてみました。
onFetchDataはattemptFetchから呼ばれています。
最後に達しているか、ローディング中か、エラーでないかをチェックした上で、addPostFrameCallbackで呼ばれます。
連続してonFetchDataが呼ばれないようにdebounce処理がされており、debounceDurationで指定できます。デフォルトは100 millisecondsのようです。
void attemptFetch() {
if (!widget.hasReachedMax && !widget.isLoading && !widget.hasError) {
WidgetsBinding.instance.addPostFrameCallback((_) {
debounce(widget.onFetchData);
});
}
}
/// Default value to [InfiniteList.debounceDuration].
const defaultDebounceDuration = Duration(milliseconds: 100);
attemptFetchは最後のItemをbuildするときに呼ばれる仕組みです。(initState、didUpdateWidgetでも呼ばれる)
void onBuiltLast(int lastItemIndex) {
if (_lastFetchedIndex != lastItemIndex) {
_lastFetchedIndex = lastItemIndex;
attemptFetch();
}
}
~~~
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: effectiveItemCount,
(context, index) {
if (index == lastItemIndex) {
onBuiltLast(lastItemIndex);
}
まとめ
VGVのvery_good_inifinite_listパッケージを紹介しました。
very_good_inifinite_listそのまま取り入れるほか、内部実装も参考にできそうです。
気が向いたら、いい感じにRiverpodと組み合わせる方法を考えてそれも記事にしたいです。