公開日 2026-07-09

Flutterのルーティング入門(Navigator と go_router の使い分け)

Flutter の Navigator と go_router の役割を最小例で整理し、画面遷移、値の受け渡し、戻り先の設計を判断できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. まずは Navigator と go_router の役割を分ける
  6. 3. Navigator.push / pop で基本遷移を作る
  7. コードのポイント
  8. 4. 引数と戻り値をやり取りする
  9. コードのポイント
  10. コードのポイント
  11. 6. 深い階層から戻る操作と使い分けを整理する
  12. 7. まとめ

Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用) の次に、画面が 2 つを超えたあたりで詰まりやすくなるのがルーティングです。一覧から詳細へ進む流れを Navigator で始めるのか、最初から go_router でルート定義を持つのか、その分け方で迷いやすい場面が増えてきます。この記事では Navigator.push / pop の基本、引数と戻り値、go_router の最小導入、深い階層からの戻り方を順番に整理します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、Dart 入門、基本 UI、タブ切り替え、カスタム Widget 分割までは終わった人
  • 一覧画面から詳細画面へ進むところで、Navigatorgo_router のどちらを選ぶか迷いやすい人
  • 画面間で値を渡したり、深い階層から一覧へ戻したりする方法を最小例で確認したい人

この記事で到達する状態

  • Navigator.push / pop で基本遷移を作れる
  • コンストラクタ引数と戻り値で画面間の値の受け渡しができる
  • go_router の最小セットアップと route 定義を読める
  • Navigatorgo_router の使い分けを、戻り方とルート管理の観点で説明できる

非対象

  • RouterDelegate / RouteInformationParser の詳細
  • 認証ガード、ShellRoute、deep link の本格設計
  • Riverpod などの状態管理との統合
  • Web 固有の URL 戦略や SEO の話

今回の主題は、Router API 全体の網羅ではありません。まずは「どの画面関係にどの遷移手段を当てるか」を整理するところまでに絞ります。ここが見えると、後で REST API 通信やログイン導線を足すときも、画面構成を崩しにくくなります。

2. まずは Navigatorgo_router の役割を分ける

最初に 2 つの役割を分けます。

選択肢向く場面最初に見るポイント典型例
Navigator画面数がまだ少なく、一覧 -> 詳細 -> 戻る流れを素直に書きたいpush / pop / 戻り値商品一覧 -> 商品詳細
go_routerルート定義をまとめたい、深い階層から戻り先を固定したい、URL で表したいGoRouter / GoRoute / context.go / context.push受注一覧 -> 詳細 -> 編集 -> 完了後に一覧へ戻す

BottomNavigationBarTabBar との違いもここで分けておきます。

  • 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 のネストで階層を表しつつ、pushgo を役割で使い分けていることです。

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() を何回重ねるか考えずに済みます。

go_router 受注編集画面 go_router 受注詳細画面 go_router 受注一覧画面

6. 深い階層から戻る操作と使い分けを整理する

迷いやすいのは「戻る」という言葉で複数の操作を混ぜることです。実際には次の 3 つを分けて考えます。

やりたいことNavigatorgo_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 を使うか」より先に、「この画面はどこから来て、どこへ戻すか」を整理すると、ルーティングの選択がぶれにくくなります。

シリーズ 16/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け) 現在の記事
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)