very_good_inifinite_list試してみた

[Flutter Advent Calendar 2023](https://qiita.com/advent-calendar/2023/flutter) 7日目の記事です。

## はじめに
どうもFlutterをしばらく使っていますが、ページング対応画面を実装するときのしっくり来る書き方が決まらないでいるK9iです。
今回VGV（Very Good Ventures）がvery_good_inifinite_listというパッケージを出していることを知ったので試してみます。

https://pub.dev/packages/very_good_infinite_list

### 余談
VGVはFlutter界隈の超有名企業です。紹介記事書いてるので興味あったら。

https://qiita.com/K9i-0/items/c294600f195150b6dfa2

## キャッチアップ
### Readme

Readmeを見たところListViewのかわりにInfiniteListというWidgetを使うみたいです👀

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2542067/5007ee94-324a-5f35-e16c-0178884d757d.png)

### exampleを試してみる
新しいパッケージを試すので、とりあえず公式リポジトリをクローンして動かしてみます。

https://github.com/VeryGoodOpenSource/very_good_infinite_list

main.dartはいくつかのExampleページに遷移するためのページで、ここではvery_good_infinite_listは使われてないようです。

https://github.com/VeryGoodOpenSource/very_good_infinite_list/blob/main/example/lib/main.dart

- Simple Example
　　- StatfulWidget内でInfiniteList使う基本の例
- Centralized Example
　　- InfiniteListでローディング・空表示・エラーを中央表示する例
- Advanced Example
　　-  Blocパッケージと組み合わせる例
- Sliver Example
　　- Sliverの中で使うInfiniteListを使う例

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2542067/6f9a66d4-8768-8570-87e2-7f814e2bb1c7.png" width=50%>

#### Simple Example

クラス名こそ違いますが、Readmeに乗っていた例と同じような感じです。
separatorBuilderがある点などはListView.separatedと近いほか、isLoadingやonFetchDataなどページング対応画面を実装する上で便利な引数があります。

```dart:sample_example.dart
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を渡していました。

```dart:infinite_list.dart
  @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にすれば良いようです。

```dart:centralized_examples.dart
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を有効にするかどうかの差

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2542067/d59d6ce6-cae1-9936-020e-63b29862f877.png" width=50%><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2542067/7b0535a9-3057-652d-83a8-5ad88b3e8f13.png" width=50%>

内部的にはcenterLoadingなどがtrueだとSliverCentralizedで各builderをラップしてました。
ローディングに関してはすでにitemがあるときはcenterにならない配慮もありますね。
空表示はいいとして、エラーは2ページ目以降でエラーのときにitemを表示しつつ、末尾がエラー表示みたいなのには対応してなさそうです。Snackbarとかで対応するのがいいのかな？

```dart:sliver_infinite_list.dart
    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を使ってます。
（せやろなと言った感じ）

```dart:sliver_centralized.dart
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を内部で使っており、使い方も同じですね。

```dart:sliver_example.dart
  @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のようです。

```dart:sliver_infinite_list.dart
  void attemptFetch() {
    if (!widget.hasReachedMax && !widget.isLoading && !widget.hasError) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        debounce(widget.onFetchData);
      });
    }
  }
```

```dart:defaults.dart
/// Default value to [InfiniteList.debounceDuration].
const defaultDebounceDuration = Duration(milliseconds: 100);
```

attemptFetchは最後のItemをbuildするときに呼ばれる仕組みです。（initState、didUpdateWidgetでも呼ばれる）

```dart:sliver_infinite_list.dart
  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と組み合わせる方法を考えてそれも記事にしたいです。