🐣

【Flutter】shrinkWrapを卒業!ListViewの中でGridViewを使う

2024/11/25に公開

ListViewの中でGridViewを使おうとして以下エラーが出て shrinkWrap=true を利用した経験があるのではないでしょうか。

════════ Exception caught by rendering library ═════════════════════════════════
RenderBox was not laid out: RenderViewport#4ea54 NEEDS-LAYOUT NEEDS-PAINT NEEDS-COMPOSITING-BITS-UPDATE
'package:flutter/src/rendering/box.dart':
Failed assertion: line 2164 pos 12: 'hasSize'

shrinkWrapとは?

  • ListViewBuilder/GridViewBuilderは画面に映っている範囲に限定してWidgetを描画することでパフォーマンスを良くしています
  • Listの中にListのWidgetを生成する際、ListViewの大きさをどこまで作るか困っちゃうのです
  • 公式動画にあるように、小さな窓から窓を覗く感じのイメージが分かりやすいです

https://www.youtube.com/watch?v=LUqDNnv_dh0

shrinkWrapを有効にすると、ListViewはリスト全体の大きさを計算し、リストのコンテンツに合わせて自身の高さを縮めます。これにより、ListViewを他のウィジェットの中に配置することが可能になります。

ただし、この動作にはトレードオフがあります。shrinkWrapを有効にすると、リスト全体を一度に計算する必要があるため、要素の数が多い場合や大規模なリストを扱う場合に、パフォーマンスが低下します。

GridListを例にして、ListViewBuilderの動きと、shrinkWrapを使わずCustomSliverで実装する例を紹介します。

ListViewBuilderの特徴

違いを分かりやすくするため、「10秒のカウントダウンをするWidget」を作成してGridViewで100個表示します。

(サンプルコード)10秒のカウントダウンするWidget
import 'dart:async';
import 'package:flutter/material.dart';

class CountDownBox extends StatefulWidget {
  const CountDownBox({Key? key}) : super(key: key);

  
  _CountDownBoxState createState() => _CountDownBoxState();
}

class _CountDownBoxState extends State<CountDownBox> {
  int count = 10;
  Timer? timer;

  
  void initState() {
    startCountdown();
    super.initState();
  }

  // カウントダウンを開始する関数
  void startCountdown() {
    timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      setState(() {
        if (count > 0) {
          count--;
        } else {
          timer.cancel();
        }
      });
    });
  }

  
  void dispose() {
    timer?.cancel();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Container(
      color: Colors.blue,
      child: Align(
        alignment: Alignment.center,
        child: Text(
          '$count',
          style: const TextStyle(fontSize: 24),
        ),
      ),
    );
  }
}
(サンプルコード)GridView.builder
return GridView.builder(
      // shrinkWrap: false, // [note] default is false
      itemCount: 100,
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      itemBuilder: (context, index) {
        return const CountDownBox();
      },
    );

  • 描画されている範囲にいるときにのみボックスがカウントダウンされる
  • スクロールで描画範囲外になると破棄される
  • 再度スクロールで描画範囲内になると生成されて描画&カウントダウンされる

このように必要なときに必要な分だけ描画することでパフォーマンスを上げています

shrinkWrapを使う場合

(サンプルコード)shrinkWrap=trueのリスト
  Widget buildShrinkWrappedGridViewListWidget() {
    return GridView.builder(
      shrinkWrap: true, // [note] enable shrinkWrap to solove size issue
      physics: const NeverScrollableScrollPhysics(), // [note] disable scroll
      itemCount: 100,
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      itemBuilder: (context, index) {
        return const CountDownBox();
      },
    );
  }

  Widget body() {
    return ListView(
      children: [
        const Text("SHRINK WRAP LIST"),
        buildShrinkWrappedGridViewListWidget()
      ],
    );
  }

  • 画面生成時点ですべてのボックスのカウントダウンがされる
  • 描画範囲外にスクロールしても破棄&再生成されることがない
    事前にすべてを計算し描画しているのがわかります。

CustomSliverList

  Widget buildCustomSliverListWidget() {
    List<Widget> children = [];
    int itemCount = 100;

    // [note] build non-list widget
    Widget header = const SliverToBoxAdapter(
      child: Text("CUSTOM SLIVER LIST"),
    );
    children.add(header);

    // [note] build list widget
    Widget listWidget = SliverGrid(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      delegate: SliverChildBuilderDelegate(childCount: itemCount,
          (BuildContext context, int index) {
        return const CountDownBox();
      }),
    );

    children.add(listWidget);

    return CustomScrollView(
      slivers: children,
    );
  }


  • 描画されている範囲にいるときにのみボックスがカウントダウンされる
  • スクロールで描画範囲外になると破棄される
  • 再度スクロールで描画範囲内になると生成されて描画&カウントダウンされる
    このようにGridViewBuilderで実装した時のメリットである必要分のみ描画されていて、パフォーマンスが良いです。

(おまけ)ListViewのSliverを作る

SliverGridではなくSliverListでつくれます

サンプルコード
int itemCount = 100;
Widget listWidget = SliverList(
  delegate: SliverChildBuilderDelegate(
    (BuildContext context, int index) {
      return const CountDownBox();
    },
    childCount: itemCount,
  ),
);

おわりに

ListViewBuilderの中で画像を使っている場合、特に気を付けると良いです。

例えば、NetworkImageを利用している場合、shrinkWrap:trueによって、すべてのWidgetをビルドするため一気にHTTPリクエストが発生し、ネットワークの負荷と画面描画の負荷の両方でたくさんパワーを使ってしまいます。

良いFlutterライフを~!

Discussion