FlutterでEXCEPTION CAUGHT BY RENDERING LIBRARYが出た時の対応方法

久々Flutter。
今日のお題はEXCEPTION CAUGHT BY RENDERING LIBRARYエラー。
読んで字のごとく、レンダリングに関するエラーです。

例として、こんな画面を考えてみます。

  • スクロールを検知して_currentIndexを設定
  • floatingActionButtonタップで_awesomeListから_currentIndexの要素を消す
    (画面からも_awesomeListウィジェットを削除)
class _AwesomeState extends State<Awesome> {
  int _currentIndex = 0;
  int _awesomeWidgetHeight = 500;
  List<Widget> _awesomeList = [
    // なんかすごくいいリスト
  ];

  Widget somethingAwesomeSingleScrollView() {
   // なんかすごくいいリストを使ってすごくいいスクロールビューを返す
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('タイトル'),
      ),
      body: NotificationListener<ScrollNotification>(
        child: somethingAwesomeSingleScrollView(),
        onNotification: (ScrollNotification scrollInfo) {
          setState(() {
            _currentIndex = (scrollInfo.metrics.pixels / _awesomeWidgetHeight).toInt();
          });
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Text('Awesome!'),
        onPressed: () {
          setState(() {
            _awesomeList.removeAt(_currentIndex);
          });
        },
      ),
    );
  }
}

※ スクロールの度にonNotificationが発火するのでめちゃ重です。

おおむねうまく動作するのですが、_awesomeListの最後の要素を消そうとすると表題のエラーが出てしまい、うまくいきません。

I/flutter (21487): ══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═════════════════════════════════════════════════════════
I/flutter (10000): The following assertion was thrown during performLayout():
I/flutter (10000): Build scheduled during frame.
I/flutter (10000): While the widget tree was being built, laid out, and painted, a new frame was scheduled to rebuild
I/flutter (10000): the widget tree. This might be because setState() was called from a layout or paint callback. If a
I/flutter (10000): change is needed to the widget tree, it should be applied as the tree is being built. Scheduling a
I/flutter (10000): change for the subsequent frame instead results in an interface that lags behind by one frame. If
I/flutter (10000): this was done to make your build dependent on a size measured at layout time, consider using a
I/flutter (10000): LayoutBuilder, CustomSingleChildLayout, or CustomMultiChildLayout. If, on the other hand, the one
I/flutter (10000): frame delay is the desired effect, for example because this is an animation, consider scheduling the
I/flutter (10000): frame in a post-frame callback using SchedulerBinding.addPostFrameCallback or using an
I/flutter (10000): AnimationController to trigger the animation.

大事なのはここ。

While the widget tree was being built, laid out, and painted, a new frame was scheduled to rebuild the widget tree.
This might be because setState() was called from a layout or paint callback.

直訳すると、こんな感じ。

ウィジェットツリーが構築・レイアウト・描画されている間に、ウィジェットツリーがリビルドされる新しいフレーム(?)がスケジュールされました。
これはsetState()がレイアウトまたは描画コールバックから呼ばれたせいかもしれません。

※ Flutterのウィジェットツリーをまだ理解していないのですが、こちらに詳しいです。

今回おそらくこんな流れでエラーが発生したのだろうと考えています。

  1. (前提: _awesomeListを消したことでウィジェットツリーの再描画が行われる)
  2. _awesomeListの最後の要素を消したことで画面サイズが変更になる
  3. 画面サイズが変更されたことでスクロールが起きてonNotification内のsetState()が走る
  4. _awesomeList要素削除によるウィジェットツリーの再描画中に新しく再描画処理が走る
    setState()内の処理ではなく、メッセージ通りsetState()が走ること自体が原因

なので、onNotificationをこんな風に変更します。

class _AwesomeState extends State<Awesome> {
  int _currentIndex = 0;
  int _awesomeWidgetHeight = 500;
  List _awesomeList = [];

  Widget somethingAwesomeSingleScrollView() {
   // なんかすごくいいスクロールビューを返す
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('タイトル'),
      ),
      body: NotificationListener<ScrollNotification>(
        child: somethingAwesomeSingleScrollView(),
        onNotification: (ScrollNotification scrollInfo) {
          // calculateCurrentIndexは現在の要素を示すIndex
          int calculateCurrentIndex = (scrollInfo.metrics.pixels / _awesomeWidgetHeight).toInt();
          // _currentIndexが最後の要素でない場合のみ_currentIndexを更新する
          // _calculateIndexは0始まりなので、_awesomeList.length - 1の必要はない
          if (_awesomeList.length != calculateIndex) {
            setState(() {
              _currentIndex = calculateIndex;
            });
          }
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Text('Awesome!'),
        onPressed: () {
          setState(() {
            _awesomeList.removeAt(_currentIndex);
          });
        },
      ),
    );
  }
}

ここで注意するのは、if (_awesomeList.length != calculateIndex)が必要な理由。_awesomeList.removeAt(_currentIndex)が走った瞬間はまだ画面幅は再描画されていないということ。
_awesomeList.removeAt(_currentIndex);が走り、_awesomeList.lengthが1マイナスになっても、calculateIndexはあくまでスクロール量(画面高さ)から計算しているので、_awesomeListの要素が消された瞬間はまだ_awesomeListがあった時のIndexを示しています。
それからスクロール(画面高さを縮小)して、calculateIndex_awesomeListの最後の要素を示すようになった時(=スクロールが完了した時)に初めてsetState()が呼ばれる、という処理になり、エラーが解消されます。

すごくちょっとしたことなのですが、ネイティブアプリ(フロント?)に慣れないとこの辺りが中々理解できなかったので、まとめました。
ネイティブアプリのレンダリング周りはとても奥が深くて面白そうです!