📌

【Flutter】複数のリストをならべる方法(Column, ListView, Sliver)

2023/02/19に公開

何をしたかったか

異なるデータを参照するリストを同じ画面に複数表示したい
例えば以下のようなレイアウトを作成する場合

お知らせ一覧
|- お知らせ 1
|- お知らせ 2
|- お知らせ 3 (あるだけ全部)
お気に入り一覧
|- お気に入り 1
|- お気に入り 2
|- お気に入り 3
|- お気に入り 4 (あるだけ全部)
履歴
|- 履歴 1
|- 履歴 2
|- 履歴 3 (あるだけ全部)

(あるだけ全部表示するのはレイアウト的に微妙な気がしますが例として😇)

実装方法

1. Columnで実装

    final list1 = List.generate(
      30, (int i) => TestTile(listIndex: 0, tileIndex: i),
    );
    final list2 = List.generate(
      30, (int i) => TestTile(listIndex: 1, tileIndex: i),
    );
    return Scaffold(
      appBar: AppBar(),
      body: SingleChildScrollView(
        child: Column(
          children: [
            const Text('List1'),
            ...list1,
            const Text('List2'),
            ...list2,
          ],
        ),
      ),
    );

'Column'内に直接Widgetの配列として、リストを追加する。
以下2つの方法に比べシンプルな方法です

      body: SingleChildScrollView(
        child: Column(
          children: [

bodyの子に'Column'を設定するだけだと、画面外にリストがはみ出してしまうためレイアウトエラーが発生&スクロールができないため'SingleChildScrollView'を親に設定しています。

2. ListViewで実装

    final list1 = List.generate(
      30, (int i) => TestTile(listIndex: 0, tileIndex: i),
    );
    final list2 = List.generate(
      30, (int i) => TestTile(listIndex: 1, tileIndex: i),
    );

    final listView1 = ListView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      itemBuilder: (_, i) => i == 0 ? const Text('List1') : list1[i - 1],
      itemCount: list1.length + 1,
    );
    final listView2 = ListView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      itemBuilder: (_, i) => i == 0 ? const Text('List2') : list2[i - 1],
      itemCount: list2.length + 1,
    );

    final listItems = [
      listView1,
      listView2,
    ];

    return Scaffold(
      appBar: AppBar(),
      body: ListView.builder(
        itemBuilder: ((_, i) => listItems[i]),
        itemCount: listItems.length,
      ),
    );

複数のListViewでリストを表示します。
ListViewは用意されている領域すべてを埋めるような特徴があるので、ListView(Columnも)の子にListViewを配置すると、高さの上限設定が存在しないため無限に高さを広げようとしてレイアウトエラーになってしまいます。

そのため、以下の設定が必要になります。

      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
  • shrinkWrap : trueにすることで、ListViewの高さをListView内で表示している要素をすべて表示したときの高さになります。(ListView.height = item*itemCount)
  • physics : デフォルトの設定だと、ListViewは自身のView内をスクロールすると、自身の要素がスクロールするようになっています。そのため、ListView内にListViewを配置すると親のListViewのスクロール処理と、子のListViewのスクロール処理が重なってしまい、正常にスクロールできなくなってしまいます。そのため、子のListViewについては、NeverScrollableScrollPhysicsを設定しスクロールをできないように設定する必要があります。

3. Sliverで実装

    final list1 = List.generate(
      30, (int i) => TestTile(listIndex: 0, tileIndex: i),
    );
    final list2 = List.generate(
      30, (int i) => TestTile(listIndex: 1, tileIndex: i),
    );

    return Scaffold(
      appBar: AppBar(),
      body: CustomScrollView(
        slivers: [
          SliverList(
            delegate: SliverChildListDelegate([
              const Text('List1'),
              ...list1,
            ]),
          ),
          SliverList(
            delegate: SliverChildListDelegate([
              const Text('List2'),
              ...list2,
            ]),
          ),
        ],
      ),
    );

Sliverというクラス群を利用して、リストを表示します。
CustomScrollViewというクラスにあるsliversというパラメーターに要素を追加していきます。
今回はListViewのような見た目を再現したかったのでSliverListを利用しています。

注意点

上記3つの方法で要素数に上限がなく、表示するWidget上に表示する内容が多くメモリに負荷がかかる可能性がある場合、1,2の方法については避けるべきです。
逆に表示する要素が決まっており、少数でメモリ的にもあまり圧迫しないのであれば利用することは全然ありかと思います。

Columnにはそもそもメモリの管理機能はなく、子要素に設定されたすべての要素を常に表示されています。
ListView.builderには画面に表示されなくなった要素は一度破棄され必要以上にメモリが圧縮されない仕組みが存在しています。
ただ、2の方法ではListView内にListViewを配置するためにshrinkWrapというパラメータをtrueにしている影響ですべての要素を表示しているためColumn同様、表示されない要素がメモリ上破棄されることはなくメモリを圧迫してしまいます。

検証用のWidget
initStateでWidgetが生成されるタイミングで呼ばれ、
disposeはWidgetが破棄されるタイミングでよばれます。
ListView.builderSliverを利用する場合、基本的には表示されなくなった場合にdisposeが呼ばれ、表示されるタイミングでinitStateが呼ばれます。

class TestTile extends StatefulWidget {
  const TestTile({super.key, required this.listIndex, required this.tileIndex});

  final int listIndex;
  final int tileIndex;

  @override
  State<StatefulWidget> createState() => TestTileState();
}

class TestTileState extends State<TestTile> {
  @override
  void initState() {
    super.initState();

    print('initState List:${widget.listIndex} : Tile:${widget.tileIndex}');
  }

  @override
  void dispose() {
    super.dispose();

    print('dispose List:${widget.listIndex} : Tile:${widget.tileIndex}');
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('List:${widget.listIndex} : Tile:${widget.tileIndex}'),
    );
  }
}

以下、画面遷移直後のTerminalに表示されたログ

1. Columnで実装
1. Columnでの実装の場合は、list1list2の内容がすべて表示されているとみなされ、すべての要素のinitStateが呼ばれています。

2. ListViewで実装
2. ListViewで実装での実装の場合は、listView1の要素がすべて表示されているとみなされます。下にスクロールをしてlistView2が表示されようとすると、listView2の要素が一斉に表示されます。

3. Sliverで実装
3. Sliverで実装での実装の場合は、list1の表示されている部分(@buffer)の要素のみ表示されているとみなされ、必要最低限のinitStateが呼ばれます。

上記で記載しているコードは以下リポジトリにアップしています。
https://github.com/shimamura4123/list_sample

Discussion