Flutterで複数のListViewを組み合わせた画面を作成する

## はじめに

Flutterで、1つの画面内に複数のListViewやGridViewを並べたい時があったので、その際の記録を記述しています。
（GridViewを使った実装コード例はありません）

## まとめ
Sliver使おう。

## シンプルにListViewをネストし並べる

### 実装概要
簡単に実装して見るとしたら、ListViewの中にさらに複数のListView (`ListView.physics`にNeverScrollableScrollPhysicsを指定しておく) という実装かなあ、と思います。

```dart
ListView(
  shrinkWrap: true,
  children: <Widget>[
    _ScrollView(label: '1st', baseColor: Colors.blue),
    _ScrollView(label: '2nd', baseColor: Colors.yellow),
    _ScrollView(label: '2nd', baseColor: Colors.yellow),
    ...
  ],
);

//_ScrollViewの中身
ListView.builder(
  shrinkWrap: true,
  physics: const NeverScrollableScrollPhysics(),
  itemCount: itemCount,
  itemBuilder: (_, int index) {
    final String label = '${widget.label}_item_$index';
    return _ListItem(..);
  },
)
```

親のListViewの代わりにSingleChildScrollViewとColumnの組み合わせで実装される方もいらっしゃると思います。

### 実装（コード）
サンプルコード全文は以下に置いときます。

<details><summary>コードサンプル</summary><div>

```dart

class Body extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView(
      shrinkWrap: true,
      children: <Widget>[
        _ScrollView(
          key: const ValueKey<String>('ListView.children#1'),
          label: 'ListView.children#1',
          baseColor: Colors.blue,
        ),
        _ScrollView(
          key: const ValueKey<String>('ListView.children#2'),
          label: 'ListView.children#2',
          baseColor: Colors.yellow,
        ),
        _ScrollView(
          key: const ValueKey<String>('ListView.children#3'),
          label: 'ListView.children#3',
          baseColor: Colors.red,
        ),
        _ScrollView(
          key: const ValueKey<String>('ListView.children#4'),
          label: 'ListView.children#4',
          baseColor: Colors.orange,
        ),
        _ScrollView(
          key: const ValueKey<String>('ListView.children#5'),
          label: 'ListView.children#5',
          baseColor: Colors.green,
        ),
      ],
    );
  }
}

class _ScrollView extends StatefulWidget {
  const _ScrollView({
    Key key,
    this.label,
    this.baseColor,
  }) : super(key: key);
  final String label;
  final Color baseColor;

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

class __ScrollViewState extends State<_ScrollView> {
  final itemCount = 50;

  @override
  Widget build(BuildContext context) {
    print('$runtimeType (${widget.key}) build');
    return ListView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      itemCount: itemCount,
      itemBuilder: (_, int index) {
        final String label = '${widget.label}_item_$index';
        return _ListItem(
          key: ValueKey<String>(label),
          child: Container(
            constraints: const BoxConstraints.expand(height: 48),
            color: widget.baseColor.withOpacity(1 / (index % 5 + 1)),
            child: Center(child: Text(label)),
          ),
        );
      },
    );
  }
}

class _ListItem extends StatelessWidget {
  const _ListItem({Key key, this.child}) : super(key: key);
  final Widget child;

  @override
  Widget build(BuildContext context) {
    print('$runtimeType ($key) build');
    return child;
  }
}

```
</details></div>

### この実装での問題

（1）パフォーマンスがあまりよくないです。
（2）スクロールをして _ScrollView から次に続く _ScrollView が表示されようとした時、一瞬スクロールが引っかかります
（3）最上位のListView.childrenに指定した _ScrollView (実体はほぼListView) がビルドされたと同時に、 _ScrollView の中のListView.childrenのWidgetがビルドされてしまいます。

（2）については（3）のように「_ScrollViewがビルドされたと同時に_ScrollViewのListView.childrenのWidgetがビルド」されてしまうのが要因と思います。
特に（3）についてですが、ListViewの子要素はlazilyに作られるはずなのに、その恩恵を受けられていません。

また_ScrollViewのListView.childrenのWidgetのうち、画面外に移動したものでも、それらが破棄されることはありません。

## どうするか
CustomScrollViewとSliverを使います。
ListViewに以下のようにあります。

> Transitioning to CustomScrollView
A ListView is basically a CustomScrollView with a single SliverList in its CustomScrollView.slivers property.

> If ListView is no longer sufficient, for example because the scroll view is to have both a list and a grid, or because the list is to be combined with a SliverAppBar, etc, it is straight-forward to port code from using ListView to using CustomScrollView directly.

これを読んでCustomScrollViewを使う方が良さそうだと思いました。（具体的な根拠なし）

ただ`CustomScrollView.sliver`にはRenderSliverを生成するWidgetじゃなければダメと[CustomScrollViewのドキュメント](https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html)にあるので、

> Widgets in these slivers must produce RenderSliver objects.

単にListViewをCustomScrollViewにするだけでは動かなそうです。

## CustomScrollView + Sliverで作り直した例

### 実装修正・説明
修正後のコードはこの後の 「実装（Sliverを使用したコード）」 をご確認ください。
まず最上位のListViewをCustomScrollViewに置き換えます。

```dart
class BodyWithSliver extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      shrinkWrap: true,
      slivers: <Widget>[
        ...
      ],
    );
  }
```

_ScrollViewはどうしよう。。
とりあえずSliverListの継承クラスにしてやりきろうと思います。
`SliverList.delegate` にはSliverChildBuilderDelegateを使用しました。

```dart

class _ScrollViewSliver extends SliverList {
  _ScrollViewSliver({
    Key key,
    String label,
    Color baseColor,
    @required int itemCount,
  })  : assert(itemCount != null && itemCount > 0),
        super(
          key: key,
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              final String _label = '${label}_item_$index';
              return _ListItem(
                key: ValueKey<String>(_label),
                child: Container(
                  constraints: const BoxConstraints.expand(height: 48),
                  color: baseColor.withOpacity(1 / (index % 5 + 1)),
                  child: Center(child: Text(_label)),
                ),
              );
            },
            childCount: itemCount,
            addSemanticIndexes: false,
          ),
        );
}
```

SliverChildListDelegateでも同じように作れました。
なお、[SliverChildBuilderDelegateのドキュメント](https://api.flutter.dev/flutter/widgets/SliverChildBuilderDelegate-class.html)には

- IndexedSemanticsでAnnotateすることが必要
- 以下の内容より、1つのscroll view (多分CustomScrollViewのことだと思うが)に複数のdelagateがある場合はindexが正しくない

> If multiple delegates are used in a single scroll view, then the indexes will not be correct by default.

という内容もありましたが、以下の理由から `addSemanticIndexes: false` としています。

- IndexedSemanticsでAnnotateしてもらわなくても表示やスクロールには問題なさそう
- IndexedSemanticsでAnnotateしてもらうとTalkOver/Voiceoverでスクロール位置に応じたお知らせをしてくれる（？）、という機能があるようですが、それは今は必要なし

これでほぼ実装完了で、ログを確認したところ、以下が解決できていました。
> （2）スクロールをして_ScrollViewから次に続く_ScrollViewが表示され長とした時、一瞬スクロールが引っかかります
> （3）最上位のListView.childrenに指定した _ScrollView (実体はほぼListView) がビルドされたと同時に、_ScrollViewのListView.childrenのWidgetがビルドされてしまいます。

パフォーマンスはどうなんだろう・・・いっぺんにビルドがされない（lazilyなビルドをする）ので、パフォーマンスは良くなっている、と思ってます。。

### 実装（Sliverを使用したコード）
サンプルコード全文は以下に置いときます。

<details><summary>コードサンプル</summary><div>

```dart

class BodyWithSliver extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      shrinkWrap: true,
      slivers: <Widget>[
        _ScrollViewSliver2(
          key: const ValueKey<String>('ListView.children#1'),
          label: 'ListView.children#1',
          baseColor: Colors.blue,
          itemCount: 50,
        ),
        _ScrollViewSliver(
          key: const ValueKey<String>('ListView.children#2'),
          label: 'ListView.children#2',
          baseColor: Colors.yellow,
          itemCount: 50,
        ),
        _ScrollViewSliver2(
          key: const ValueKey<String>('ListView.children#3'),
          label: 'ListView.children#3',
          baseColor: Colors.red,
          itemCount: 50,
        ),
        _ScrollViewSliver(
          key: const ValueKey<String>('ListView.children#4'),
          label: 'ListView.children#4',
          baseColor: Colors.orange,
          itemCount: 50,
        ),
        _ScrollViewSliver2(
          key: const ValueKey<String>('ListView.children#5'),
          label: 'ListView.children#5',
          baseColor: Colors.green,
          itemCount: 50,
        ),
      ],
    );
  }
}

/// SliverChildBuilderDelegateを使った例
class _ScrollViewSliver extends SliverList {
  _ScrollViewSliver({
    Key key,
    String label,
    Color baseColor,
    @required int itemCount,
  })  : assert(itemCount != null && itemCount > 0),
        super(
          key: key,
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              final String _label = '${label}_item_$index';
              return _ListItem(
                key: ValueKey<String>(_label),
                child: Container(
                  constraints: const BoxConstraints.expand(height: 48),
                  color: baseColor.withOpacity(1 / (index % 5 + 1)),
                  child: Center(child: Text(_label)),
                ),
              );
            },
            childCount: itemCount,
            addSemanticIndexes: false,
          ),
        );
}

/// SliverChildListDelegateを使った例
class _ScrollViewSliver2 extends SliverList {
  _ScrollViewSliver2({
    Key key,
    String label,
    Color baseColor,
    @required int itemCount,
  })  : assert(itemCount != null && itemCount > 0),
        super(
          key: key,
          delegate: SliverChildListDelegate(
            List<Widget>.generate(itemCount, (int index) {
              final String _label = '${label}_item_$index';

              return _ListItem(
                key: ValueKey<String>(_label),
                child: Container(
                  constraints: const BoxConstraints.expand(height: 48),
                  color: baseColor.withOpacity(1 / (index % 5 + 1)),
                  child: Center(child: Text(_label)),
                ),
              );
            }),
            addSemanticIndexes: false,
          ),
        );
}

class _ListItem extends StatelessWidget {
  const _ListItem({Key key, this.child}) : super(key: key);
  final Widget child;

  @override
  Widget build(BuildContext context) {
    print('$runtimeType ($key) build');
    return child;
  }
}

```
</details></div>