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の基礎文法を素早くインプットできる/









コメント