[Flutter]ReorderableListViewの注意点:Navigatorで再描画される?

※当サイトは、アフィリエイト広告を利用しています

アプリ開発奮闘記

Flutterで開発中に、「Navigator」による画面遷移時のエラーにハマり、試行錯誤の結果、どうやら使用していた「ReorderableListView」に要因があると分かりました。

 

ネット上に同じ状況の情報が見つからず、Chat-GPTに聞いてもズバリの回答が得られなかったので、同じ現象にお悩みの方の参考になればと思い、解決に至った過程と、具体的な対処法(結論)を共有します。

 

※自分が未熟なだけで、皆さんにとっては常識的な内容かもしれず、その際は恐縮ですm(_ _)m。

 

概略(結論)は以下の通りです。

 

  • Navigator.pushReplacementの画面遷移でRangeErrorが発生
  • 原因は、ReorderableListViewを使い、画面遷移前にリスト変数を初期化していた事(ListViewでは発生しない)
  • 初期化のタイミングを変更して解決

 

詳細については、下記をご参照ください。

 

前提とする環境

  • PC:MacBook Pro(Intel Core i5)
  • OS:macOS Sonoma 14.5
  • Flutter:3.19.6 ※古いです...
  • Android Studio:Koala 2024.1.1 Patch 1
  • Xcode:15.4

 

発生したエラーとエラー発生時のコード例

リスト表示している画面から、Navigator.pushReplacementで別画面に画面遷移させようとすると、以下のようなエラーが出る状況になりました。

 

RangeError (index): Invalid value: Valid value range is emptv: 0

 

エラーが出た状態のコードを、かなり簡略化(デフォルメ)したサンプルコードとして下記に示します。

 

要点としては、以下のとおりです。

 

  • 遷移元の画面は、リストを表示している
  • リスト表示には、手動で並べ替えができる「ReorderableListView.builder」を用いている
  • 画面遷移前に、リスト変数を初期化している
  • リスト変数は、画面表示用のクラス(View層:SampleScreenO)内では定義せず、他画面からも参照できるように、シングルトンのクラス(ViewModel層:SampleDataClassO)内で定義している
    ※実際のコードでは、MVVMの実装にRiverpodを使っていますが、ここでは単純化のためシングルトンクラスにしています。

 

// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの)
// の名前の末尾には、大文字のオー「O」をつけています
// ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、
//  自分で作成したもの(名前の変更可のもの)の区別をしやすくするため

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Test",
      theme: ThemeData.light(
        useMaterial3: false,
      ),
      home: SampleScreenO(),
    );
  }
}

/// リスト変数を保持するシングルトンクラス(ViewModel層に相当)
class SampleDataClassO {
  SampleDataClassO._();
  static final SampleDataClassO instanceO = SampleDataClassO._();
  factory SampleDataClassO() {
    return instanceO;
  }

  // リスト変数をここで定義
  List<String> listStringO = [];
}

/// 初期画面を表示するクラス(View層に相当)
class SampleScreenO extends StatefulWidget {
  @override
  _SampleScreenOState createState() => _SampleScreenOState();
}

class _SampleScreenOState extends State<SampleScreenO> {

  @override
  void initState() {
    super.initState();
    // リスト変数にデータを格納
    for (int iO = 0; iO <= 20; iO++) {
      SampleDataClassO().listStringO.add("テキスト$iO");
    }
  }

  @override
  Widget build(BuildContext context) {
    return  Scaffold(
      appBar: AppBar(
        title: Text("テスト"),
      ),
      body: ReorderableListView.builder(

        // 並替え処理
        onReorder: (int oldIndexO, int newIndexO) {
          if (oldIndexO < newIndexO) {
            newIndexO -= 1;
          }
          final String itemO = SampleDataClassO().listStringO.removeAt(oldIndexO);
          SampleDataClassO().listStringO.insert(newIndexO, itemO);
          setState(() {});
        },

        itemCount: SampleDataClassO().listStringO.length,

        // リストを表示
        itemBuilder: (BuildContext contextO, int indexO) {

          // リストが描画されたタイミングを確認するためのログ
          debugPrint("$indexO番目を表示");

          return ListTile(
            key: Key("$indexO"),
            title: Text(SampleDataClassO().listStringO[indexO]),
            onTap: () {
              // 画面遷移前にリスト変数を初期化
              SampleDataClassO().listStringO = [];

              Navigator.pushReplacement(
                context,
                MaterialPageRoute(
                  builder: (_) => const SecondScreenO(),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

/// 遷移後の画面を表示するクラス(View層に相当)
class SecondScreenO extends StatefulWidget {
  const SecondScreenO({super.key});

  @override
  State<SecondScreenO> createState() => _SecondScreenOState();
}

class _SecondScreenOState extends State<SecondScreenO> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: ElevatedButton(
        child: Text("戻る"),
        onPressed: () {
          Navigator.pushReplacement(
            context,
            MaterialPageRoute(
              builder: (_) => SampleScreenO(),
            ),
          );
        },
      ),
    );
  }
}

 

このコードを実行すると、以下のようなエラーが出ると思います。

※但し、後述「考察」のとおり、エラーが必ず出るわけではなく、ご利用の環境・状況次第では、エラーを再現できない可能性があることをご了承下さい。

 

 

原因究明に至る過程

元の画面にはリストを表示していたので、エラー内容からすると、リスト変数に問題が生じていると推測できました。

 

確かに、画面遷移する前に、諸事情あってリスト変数を初期化しています。

ただ、リスト変数を初期化しても、そのまま次の画面に遷移するので、特にエラーは生じないはず、、と思っていました。

 

そこで、「ReorderableListView.builder」の「itemBuilder: (BuildContext contextO, int indexO) {・・・」の後に、「debugPrint」を入れてログを監視してみると、画面遷移の直前に、「debugPrint」がリストのデータ数分だけ表示されました。

つまり、画面遷移の直前に、なぜかリスト画面が再描画されている、という事のようです。

 

そこで、Chat-GPTに「そんなことあるの?」と尋ねてみると、

 

Navigator.push のような画面遷移系メソッドを実行すると、Flutterフレームワークは現在のウィジェットツリーを再評価し、必要に応じて再描画します。
Navigator.pushReplacement を使用した場合も、現在のウィジェットツリーが再評価される可能性があります。ただし、挙動が若干異なる点があるため、再評価の影響は減少する場合があります。

 

との回答でした。

なるほど。。恥ずかしながら初めて知りました。。

 

そうであるならば、確かに画面遷移前に、リスト変数を初期化してしまうと、再描画しようとした時に、変数が空っぽなので、RangeErrorになるはずです。。

 

そこで、試しにListViewとNavigatorで、簡易サンプルコードを作成し、実験してみると、、、

なぜか遷移時に再描画は発生せず、リスト変数を初期化してもエラーは発生しませんでした。

 

よく分からなくなったので、ネットを調べてみると、以下のような情報が見つかりました。

 

 

 

 

どうやらFlutter3.19以降は、「Navigator 2.0」という仕様になり、「push」や「pop」でリビルド(再描画)が発生するようになったと。。

上記2つ目の記事は大変わかりやすく(ありがとうございます!)、従前の「命令的」な画面遷移から、状態変化させる「宣言的」な画面遷移に変わったとのこと。

 

自分の場合、画面遷移には、「push」や「pop」ではなく、常に「pushReplacement」を使うのですが、恐らくこれも同じ影響を受けるのでしょう。

 

ただ、詰まってしまったのは、前述のとおり、簡易サンプルコードで試しても、リビルド(再描画)が発生しなかった点です。

 

必ずしも再描画されるとは限らないのか。。?

 

そこで、よく考えてみると、元々開発中だった実際のコードでは、「ListView.builder」ではなく、「ReorderableListView.builder」を用いていた事に気づいたため、簡易コードの方も「ReorderableListView.builder」に書き換えて実行したところ、、、

 

確かに再現できました!(画面遷移時に、RangeErrorが出ました)

※この状態のコードが、前述で掲載したコードになります。

 

当初、「ListView.builder」と「ReorderableListView.builder」に大きな違いがあるとは思えなかったので、簡易コードの方は、「ListView.builder」を使ってしまっていました。

 

この点の理由について、改めてネット上で調べましたが、解説している記事は見つからず、、。

そこで、改めてChat-GPTに尋ねたところ、下記のような回答でした。

 

ReorderableListView.builder は、内部で状態管理を行うため、画面遷移時やウィジェットツリーの変更時に再描画が発生します
ReorderableListView は、ドラッグやドロップ中のリストアイテムを追跡するために GlobalKey を使用しています。
これにより、状態の変化があるたびに再描画が必要と判断されます

 

まぁ、何となく分かるような気はしました^^;。

 

ファクトを確認しようと、Flutter公式内などを改めて漁ってみましたが、確かな情報は見つけられず、、いったん理屈は、上記のとおりという事で納得することにしました。

 

対処法(結論)

肝心のエラーへの対処法ですが、まず上記より、エラーの原因は、

 

「ReorderableListView.builder」では、画面遷移する時にリストが再描画されるが、それにもかかわらず、リスト変数を初期化してしまっていること

 

だと分かったので、リスト変数の初期化タイミングを変更すれば良い事になります。

 

具体的には、下記2通りの方法で、対処できました。

 

対処法①:Navigator.pushReplacementの完了を待った後に初期化する

「Navigator.pushReplacement」は非同期なので、単にこの後にリスト変数の初期化コードを記述するだけだと、初期化の方が先に行われてしまいます。

 

そこで、「await Navigator.pushReplacement」のように、「await」を付けて画面遷移の完了を待ち、その後で、リスト変数を初期化すれば、エラーなく遷移することができました。

 

この方法は、画面遷移後に、別の画面からも、リスト変数を初期化した状態で利用したい場合に有効な方法です。

 

対処法②:遷移元の画面に再び戻ってきた時に初期化する

「Navigator.pushReplacement」を実行した段階では、リスト変数を初期化せず、再度、元の画面に戻ってきた段階で、「initState」メソッド内で初期化します。

これにより、同様にエラーを回避できました。

 

この方法は、

  • 画面遷移後に、別の画面からこのリスト変数を利用しない場合、または
  • 別の画面からこのリスト変数を利用する際に、初期化していない状態で利用したい場合

に有効な方法かと思います。

 

エラー解消済のコード例

前述のエラーが発生するコードに対して、上記①②の対処をしたコード例を下記に示します。

 

対処①をベースに反映しており、対処②についてはコメントアウトして記載してあります。

※対処①の部分をコメントアウトし、対処②のコメントアウトを外せば、対処②で実行できます。

 

// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの)
// の名前の末尾には、大文字のオー「O」をつけています
// ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、
//  自分で作成したもの(名前の変更可のもの)の区別をしやすくするため

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Test",
      theme: ThemeData.light(
        useMaterial3: false,
      ),
      home: SampleScreenO(),
    );
  }
}

/// リスト変数を保持するシングルトンクラス(ViewModel層に相当)
class SampleDataClassO {
  SampleDataClassO._();
  static final SampleDataClassO instanceO = SampleDataClassO._();
  factory SampleDataClassO() {
    return instanceO;
  }

  // リスト変数をここで定義
  List<String> listStringO = [];
}

/// 初期画面を表示するクラス(View層に相当)
class SampleScreenO extends StatefulWidget {
  @override
  _SampleScreenOState createState() => _SampleScreenOState();
}

class _SampleScreenOState extends State<SampleScreenO> {

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

    // 対処②の場合の変更箇所
    // 画面を読み込んだ時点でリスト変数を初期化する
    // SampleDataClassO().listStringO = [];

    // リスト変数にデータを格納
    for (int iO = 0; iO <= 20; iO++) {
      SampleDataClassO().listStringO.add("テキスト$iO");
    }
  }

  @override
  Widget build(BuildContext context) {
    return  Scaffold(
      appBar: AppBar(
        title: Text("テスト"),
      ),
      body: ReorderableListView.builder(

        // 並替え処理
        onReorder: (int oldIndexO, int newIndexO) {
          if (oldIndexO < newIndexO) {
            newIndexO -= 1;
          }
          final String itemO = SampleDataClassO().listStringO.removeAt(oldIndexO);
          SampleDataClassO().listStringO.insert(newIndexO, itemO);
          setState(() {});
        },

        itemCount: SampleDataClassO().listStringO.length,

        // リストを表示
        itemBuilder: (BuildContext contextO, int indexO) {

          // リストが描画されたタイミングを確認するためのログ
          debugPrint("$indexO番目を表示");

          return ListTile(
            key: Key("$indexO"),
            title: Text(SampleDataClassO().listStringO[indexO]),

            // 対処①の場合の変更箇所
            // async を付けて onTap を非同期化する
            // onTap: () {
            onTap: () async{

              // 対処①・対処②の場合の変更箇所
              // 対処①の場合は、初期化処理する時点を、画面遷移後に移動
              // 対処②の場合は、初期化処理する時点を、initState に移動
              // // 画面遷移前にリスト変数を初期化
              // SampleDataClassO().listStringO = [];

              // 対処①の場合の変更箇所
              // await を付けて、画面遷移の完了を待たせる
              // Navigator.pushReplacement(
              await Navigator.pushReplacement(
                context,
                MaterialPageRoute(
                  builder: (_) => const SecondScreenO(),
                ),
              );

              // 対処①の場合の変更箇所
              // 画面遷移完了後にリスト変数を初期化
              SampleDataClassO().listStringO = [];
            },
          );
        },
      ),
    );
  }
}

/// 遷移後の画面を表示するクラス(View層に相当)
class SecondScreenO extends StatefulWidget {
  const SecondScreenO({super.key});

  @override
  State<SecondScreenO> createState() => _SecondScreenOState();
}

class _SecondScreenOState extends State<SecondScreenO> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: ElevatedButton(
        child: Text("戻る"),
        onPressed: () {
          Navigator.pushReplacement(
            context,
            MaterialPageRoute(
              builder: (_) => SampleScreenO(),
            ),
          );
        },
      ),
    );
  }
}

 

考察

今回のエラーは、必ずしも毎回エラーが出る訳ではなく、エラーが出ないときもあります。

 

例えば、リリースモードで実行すると、エラーが出ません。なので、そもそも今回のエラーは気にしなくても良い、という考え方もできます。

 

一方、デバックモードであっても、「ReorderableListView.builder」の「itemBuilder」プロパティで、「return」の前に入れている「debugPrint」を削除すると、エラーが出ない事があります。

また、「debugPrint」を削除せずとも、インデックス変数(ここでは「indexO」)を使用しない「debugPrint」に変更するだけで、エラーが出なくなる事もあります。

 

逆に、「return」の前に何の処理を入れていなくても、やはりエラーが出ることもあります。

 

さらに、エミュレーターに、アプリをビルド(インストール)したままで、再ビルドするとエラーが出なくなりますが、アンインストールしてから再ビルド(再インストール)すると、再びエラーが発生する、という場合もあります。

 

このように、ウィジェットツリーの再評価(リビルド・再描画)の基準は、状況によって変わるようなので、再現性を担保するのが難しいです。。

 

以上を考慮すると、リリースモードならエラーが出ない確率が高いとは言え、エラー発生の確率が0%でないならば、やはりエラーを回避するよう努めた方が良さそうです。

 

ReorderableListViewの注意点のまとめ

以上から、

 

  • 「ReorderableListView」は、画面遷移時に、全リストが再描画されてしまう可能性アリ(「ListView」だと再描画は発生しない)
  • 「ReorderableListView」を使う際は、リスト変数を初期化するタイミングには注意が必要

  

と理解しました。

 

理解不足の点も多々あると思われ、間違いがある場合はご指摘くださいm(_ _)m。

 

以上、どなたかのご参考になれば幸いです。 

 

最後までお読みいただき、ありがとうございました。

 

リリースしたアプリ(全てFlutterで開発)

 

個人アプリ開発で役立ったもの

おすすめの学習教材

超初心者向けでオススメな元Udemyの講座/

 

 \キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/

 

\Gitの基礎について無料で学べる/

 

おすすめの学習書籍

実用的image_pickerに関してかなり助けられた/

 

Dartの基礎文法を素早くインプットできる/


Dart入門 - Dartの要点をつかむためのクイックツアー

コメント

タイトルとURLをコピーしました