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の注意点のまとめ
以上から、
と理解しました。
理解不足の点も多々あると思われ、間違いがある場合はご指摘くださいm(_ _)m。
以上、どなたかのご参考になれば幸いです。
最後までお読みいただき、ありがとうございました。
リリースしたアプリ(全てFlutterで開発)
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/
コメント