Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用) の次に、画面が 2 つを超えたあたりで詰まりやすくなるのがルーティングです。一覧から詳細へ進む流れを Navigator で始めるのか、最初から go_router でルート定義を持つのか、その分け方で迷いやすい場面が増えてきます。この記事では Navigator.push / pop の基本、引数と戻り値、go_router の最小導入、深い階層からの戻り方を順番に整理します。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 入門、基本 UI、タブ切り替え、カスタム Widget 分割までは終わった人
- 一覧画面から詳細画面へ進むところで、
Navigatorとgo_routerのどちらを選ぶか迷いやすい人 - 画面間で値を渡したり、深い階層から一覧へ戻したりする方法を最小例で確認したい人
この記事で到達する状態
Navigator.push/popで基本遷移を作れる- コンストラクタ引数と戻り値で画面間の値の受け渡しができる
go_routerの最小セットアップと route 定義を読めるNavigatorとgo_routerの使い分けを、戻り方とルート管理の観点で説明できる
非対象
RouterDelegate/RouteInformationParserの詳細- 認証ガード、
ShellRoute、deep link の本格設計 - Riverpod などの状態管理との統合
- Web 固有の URL 戦略や SEO の話
今回の主題は、Router API 全体の網羅ではありません。まずは「どの画面関係にどの遷移手段を当てるか」を整理するところまでに絞ります。ここが見えると、後で REST API 通信やログイン導線を足すときも、画面構成を崩しにくくなります。
2. まずは Navigator と go_router の役割を分ける
最初に 2 つの役割を分けます。
| 選択肢 | 向く場面 | 最初に見るポイント | 典型例 |
|---|---|---|---|
Navigator | 画面数がまだ少なく、一覧 -> 詳細 -> 戻る流れを素直に書きたい | push / pop / 戻り値 | 商品一覧 -> 商品詳細 |
go_router | ルート定義をまとめたい、深い階層から戻り先を固定したい、URL で表したい | GoRouter / GoRoute / context.go / context.push | 受注一覧 -> 詳細 -> 編集 -> 完了後に一覧へ戻す |
BottomNavigationBar や TabBar との違いもここで分けておきます。
BottomNavigationBarは主要セクションの並列切り替えTabBarは同一画面内の近い分類の切り替えNavigator/go_routerは次の画面へ進み、必要なら戻る流れの管理
判断の流れを先に置くと、次の通りです。
flowchart TD
A[作りたいのはどんな移動か] --> B{主要セクションの並列切り替えか}
B -->|はい| C[BottomNavigationBar / TabBar を検討]
B -->|いいえ| D{一覧から詳細へ進み 戻る流れか}
D -->|はい| E{ルート定義を今すぐ一元管理したいか}
E -->|いいえ| F[Navigator から始める]
E -->|はい| G[go_router を検討]
D -->|いいえ| H[画面関係を再整理]
見るべきなのは画面数ではなく、戻り方と管理したい粒度です。画面が少ない段階なら Navigator だけで十分です。戻り先の固定、URL 表現、ルート追加の見通しが必要になった段階が go_router への移行目安です。
3. Navigator.push / pop で基本遷移を作る
まずは Navigator で、一覧から詳細へ進んで戻る最小例を作ります。
以降のコード例は lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要の章は DartPad でも試せます。
lib/main.dart は次の内容で作成します。
このファイルは、一覧から詳細へ進み、1 段戻るだけの最小ルーティングを確認するサンプルです。注目点は、Navigator.push で画面を積み、詳細側では Navigator.pop で戻る基本形にあります。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const OrderListPage(),
);
}
}
class OrderListPage extends StatelessWidget {
const OrderListPage({super.key});
@override
Widget build(BuildContext context) {
final orders = const [
('A-1001', '出荷待ち', '東京倉庫'),
('A-1002', 'ピッキング中', '大阪倉庫'),
('A-1003', '確認待ち', '名古屋倉庫'),
];
return Scaffold(
appBar: AppBar(title: const Text('受注一覧')),
body: ListView.builder(
itemCount: orders.length,
itemBuilder: (context, index) {
final order = orders[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
title: Text(order.$1),
subtitle: Text('${order.$2} / ${order.$3}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => OrderDetailPage(orderNo: order.$1),
),
);
},
),
);
},
),
);
}
}
class OrderDetailPage extends StatelessWidget {
const OrderDetailPage({super.key, required this.orderNo});
final String orderNo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('受注詳細 $orderNo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'受注番号: $orderNo',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
const Text('納品先: 東京都千代田区 1-2-3'),
const SizedBox(height: 8),
const Text('ステータス: 出荷待ち'),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
Navigator.pop(context);
},
icon: const Icon(Icons.arrow_back),
label: const Text('一覧へ戻る'),
),
],
),
),
);
}
}
コードのポイント
① 一覧側で次の画面をその場で積んでいる
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
title: Text(order.$1),
subtitle: Text('${order.$2} / ${order.$3}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
どこをタップしたらどこへ進むかが、ListTile の近くにそのまま書かれています。最初の 2、3 画面では、ルート定義を別へ切り出すより導線をその場で読めるほうが追いやすい構成です。
② 詳細側では 1 段戻る操作だけを持つ
FilledButton.icon(
onPressed: () {
Navigator.pop(context);
},
icon: const Icon(Icons.arrow_back),
label: const Text('一覧へ戻る'),
),
詳細画面が知っているのは「1 段戻る」という操作だけです。Navigator が分かりやすいのは、画面の関係がコードの流れにそのまま出るからで、戻り方も stack を 1 つ戻すという最小単位で表せます。
4. 引数と戻り値をやり取りする
次は「移動するだけ」で終わらせず、前の画面へ結果を返します。配送方法の選択画面を開き、選んだ値を一覧側で受け取る例です。
lib/main.dart は次の内容で作成します。
このファイルは、遷移時に引数を渡し、戻るときに結果を返す流れを 1 本で確認するサンプルです。注目点は、await Navigator.push<T>() と Navigator.pop(context, result) の対応関係です。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const ShippingMethodPage(),
);
}
}
class ShippingMethodPage extends StatefulWidget {
const ShippingMethodPage({super.key});
@override
State<ShippingMethodPage> createState() => _ShippingMethodPageState();
}
class _ShippingMethodPageState extends State<ShippingMethodPage> {
String selectedMethod = '未選択';
Future<void> openSelector() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const MethodSelectorPage(
orderNo: 'A-1001',
currentMethod: '宅配便',
),
),
);
if (!mounted || result == null) {
return;
}
setState(() {
selectedMethod = result;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('配送方法を $result に更新しました')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('配送方法の確認')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'受注 A-1001',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text('現在の配送方法: $selectedMethod'),
const SizedBox(height: 24),
FilledButton(
onPressed: openSelector,
child: const Text('配送方法を選ぶ'),
),
],
),
),
);
}
}
class MethodSelectorPage extends StatelessWidget {
const MethodSelectorPage({
super.key,
required this.orderNo,
required this.currentMethod,
});
final String orderNo;
final String currentMethod;
@override
Widget build(BuildContext context) {
const methods = ['宅配便', 'チャーター便', '店頭受取'];
return Scaffold(
appBar: AppBar(title: Text('配送方法を選ぶ $orderNo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: methods.map((method) {
final isCurrent = method == currentMethod;
return Card(
child: ListTile(
title: Text(method),
subtitle: Text(isCurrent ? '現在の設定です' : '変更候補'),
trailing: isCurrent
? const Icon(Icons.check_circle, color: Colors.teal)
: const Icon(Icons.chevron_right),
onTap: () {
Navigator.pop(context, method);
},
),
);
}).toList(),
),
);
}
}
コードのポイント
① 呼び出し元は await Navigator.push<T>() で結果を待つ
Future<void> openSelector() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const MethodSelectorPage(
orderNo: 'A-1001',
currentMethod: '宅配便',
),
),
);
if (!mounted || result == null) {
openSelector() は画面を開くだけでなく、閉じたあとに戻り値を受け取る責務も持ちます。result == null を先に弾いているため、キャンセルや未選択のまま戻ったケースも同じ流れで扱えます。
② 選択画面は pop で結果を返すだけに絞る
return Card(
child: ListTile(
title: Text(method),
subtitle: Text(isCurrent ? '現在の設定です' : '変更候補'),
trailing: isCurrent
? const Icon(Icons.check_circle, color: Colors.teal)
: const Icon(Icons.chevron_right),
onTap: () {
Navigator.pop(context, method);
},
),
);
戻り先の画面状態を直接触らず、選んだ値だけを Navigator.pop(context, method) で返しているのがポイントです。一覧、詳細、簡単な設定変更のように「前の画面で結果を反映したい」場面では、この形がそのまま使えます。
## 5. `go_router` を最小導入して route をまとめる
画面が増えてきて、ルート定義を 1 か所で見たいときに候補になるのが go_router です。
この章からはローカルのFlutterプロジェクトが必要です。DartPadでは試せません。 プロジェクトがまだない場合は、次のコマンドで作成します。
flutter create my_routing_app cd my_routing_appエミュレーターを起動するには、まず利用可能な一覧を確認します。
flutter emulators表示されたIDを指定して起動します。
flutter emulators --launch <emulator_id>環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
最初に依存を追加します。
flutter pub add go_router
受注一覧、詳細、編集の 3 画面を 1 つの router でまとめるなら、lib/main.dart は次の内容で作成します。
このファイルは、一覧、詳細、編集の 3 画面を go_router でまとめて定義する最小構成です。注目点は、GoRoute のネストで階層を表しつつ、push と go を役割で使い分けていることです。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MyApp());
}
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const OrderHomePage(),
routes: [
GoRoute(
path: 'orders/:orderNo',
builder: (context, state) {
final orderNo = state.pathParameters['orderNo']!;
return OrderDetailPage(orderNo: orderNo);
},
routes: [
GoRoute(
path: 'edit',
builder: (context, state) {
final orderNo = state.pathParameters['orderNo']!;
return OrderEditPage(orderNo: orderNo);
},
),
],
),
],
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
),
);
}
}
class OrderHomePage extends StatelessWidget {
const OrderHomePage({super.key});
@override
Widget build(BuildContext context) {
final orders = const ['A-1001', 'A-1002', 'A-1003'];
return Scaffold(
appBar: AppBar(title: const Text('受注一覧')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'go_router では path と builder を先にまとめて定義します。',
),
const SizedBox(height: 12),
...orders.map(
(orderNo) => Card(
child: ListTile(
title: Text(orderNo),
subtitle: const Text('タップで詳細へ進む'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.push('/orders/$orderNo');
},
),
),
),
],
),
);
}
}
class OrderDetailPage extends StatelessWidget {
const OrderDetailPage({super.key, required this.orderNo});
final String orderNo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('受注詳細 $orderNo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'受注番号: $orderNo',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
context.push('/orders/$orderNo/edit');
},
child: const Text('編集へ進む'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () {
context.go('/');
},
child: const Text('一覧へ戻す'),
),
],
),
),
);
}
}
class OrderEditPage extends StatelessWidget {
const OrderEditPage({super.key, required this.orderNo});
final String orderNo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('受注編集 $orderNo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('編集画面から 1 段戻るなら pop、一覧へ戻すなら go を使い分けます。'),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
context.pop();
},
child: const Text('詳細へ 1 段戻る'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () {
context.go('/');
},
child: const Text('一覧へ戻す'),
),
],
),
),
);
}
}
コードを貼り付けたらエミュレーターが起動している状態で次のコマンドを実行します。
flutter run
コードのポイント
① ルート定義を 1 か所へ集めて階層を表している
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const OrderHomePage(),
routes: [
GoRoute(
path: 'orders/:orderNo',
builder: (context, state) {
final orderNo = state.pathParameters['orderNo']!;
return OrderDetailPage(orderNo: orderNo);
},
routes: [
GoRoute(
path: 'edit',
GoRoute を入れ子にすることで、一覧の下に詳細、その下に編集がある構造をルート定義側で読めます。画面が増えてきて「どこからどこへ進めるか」を 1 か所で確認したい段階では、このまとまり方が効きます。
② push は深く進む操作、go は戻り先固定の操作として分ける
FilledButton(
onPressed: () {
context.push('/orders/$orderNo/edit');
},
child: const Text('編集へ進む'),
),
OutlinedButton(
onPressed: () {
context.go('/');
},
child: const Text('一覧へ戻す'),
),
詳細から編集へ 1 段深く進む場面では context.push(...) を使い、階層に関係なく一覧へ戻したい場面では context.go('/') を使っています。go_router が向くのは、画面遷移そのものよりルート定義の整理と戻り先の固定で、Navigator.pop() を何回重ねるか考えずに済みます。
6. 深い階層から戻る操作と使い分けを整理する
迷いやすいのは「戻る」という言葉で複数の操作を混ぜることです。実際には次の 3 つを分けて考えます。
| やりたいこと | Navigator | go_router | 向く場面 |
|---|---|---|---|
| 1 段だけ戻る | Navigator.pop(context) | context.pop() | 詳細 -> 一覧、編集 -> 詳細 |
| 一覧まで戻す | Navigator.popUntil(context, (route) => route.isFirst) | context.go('/') | 完了後は必ず一覧へ戻したい |
| 導線を作り直す | Navigator.pushAndRemoveUntil(...) | context.go('/login') や context.go('/home') | ログインし直し、購入完了後の再入場 |
Navigator.popUntil が向くのは、いま積んでいる stack の中で「どこまで戻るか」を決めたいときです。画面構成が単純なら十分と言えます。
context.go が向くのは、現在の stack を意識せず「戻り先はここ」と固定したいときです。編集、確認、完了のように階層が深くなっても、戻り先の考え方を一定にできます。
使い分けを短くまとめると次の通りです。
- まず 2、3 画面の基本遷移を作るなら
Navigator - 画面間の値の受け渡しを理解したい段階でも
Navigatorが追いやすい - ルート定義をまとめたい、戻り先を明示したい、URL でも表したいなら
go_router
「最初から go_router を入れるべきか」で迷ったら、先に自分の画面関係を紙に書くほうが早い場合が多くあります。一覧 -> 詳細だけなら Navigator で十分です。詳細 -> 編集 -> 確認 -> 完了と増えて、戻り先の規則を揃えたくなった段階で go_router へ寄せると判断しやすくなります。
7. まとめ
最初の選び方は難しく考えなくて構いません。一覧から詳細へ進み、戻り値を受け取る基本を押さえる段階では Navigator が分かりやすい構成です。ルート定義をまとめたい、深い階層から戻り先を固定したい、URL としても扱いたい条件が出てきたら go_router を検討します。
次に REST API 通信やログイン導線へ進むと、画面数が増えて戻り先も複雑になります。そのときは「どの API を使うか」より先に、「この画面はどこから来て、どこへ戻すか」を整理すると、ルーティングの選択がぶれにくくなります。