【Flutter】go_routerでの実践的な画面遷移

に公開

はじめに

Flutterアプリの開発において、画面遷移(ルーティング)の設計は、アプリの規模が大きくなるにつれて複雑になりがちです。「従来のNavigator 1.0と何が違うのか?」「BottomNavigationBarのタブ切り替えで、スクロール位置や入力状態を維持するにはどうすればいいのか?」といった疑問や課題を持つ方も多いのではないでしょうか。

本記事では、Googleが提供するgo_routerパッケージを用いて、URLベースの宣言的なルーティングを実現する方法を段階的に解説します。基本的な導入手順から、必須となるパラメータの受け渡し、そして実務レベルで求められる「ネストされたナビゲーション(ShellRoute)」の実装まで、コード例を交えて網羅しました。モダンな画面遷移を一緒に学んでいきましょう。

参考URL

https://docs.flutter.dev/ui/navigation

https://pub.dev/packages/go_router

https://github.com/flutter/packages/tree/main/packages/go_router

go_routerの概要

要点

  • 宣言的な定義: アプリ全体の画面遷移構造をコードで一元管理できます。URLベースの宣言的なルーティングを実現します。
  • URLとの同期: 画面の状態とURL(パス)が常に同期するため、Web開発との親和性が高いです。
  • ディープリンク対応: 外部からのリンク(URL)を受け取った際、適切な画面スタックを自動で復元します。
  • ネストされたナビゲーション: ShellRoute を使用することで、タブバーなどを共通化した高度なUI構築をサポートします。

Flutter公式が推奨する、URLベースの宣言的なルーティングを実現するパッケージです。
複雑なRouter API(Navigator 2.0)をラップし、開発者が直感的に扱えるシンプルなAPIを提供しています。

URLベースの簡潔な構文でアプリケーションのルーティング構造を宣言的に定義できるようにする

要点

  • 最大の違いは「命令的」か「宣言的」かというパラダイム
  • 従来のNavigator1.0は命令的な遷移
    • push()pop()のような命令型メソッドを呼び出して画面間を移動
    • Navigator 1.0が「次に何をするか」を指示する命令的な手法
  • go_routerは「現在の状態と遷移先がどうあるべきか」を定義する宣言的な手法
    • go_router「アプリにはこういうルートがある」と ルーティングを一箇所で“宣言”する
    • ルートが一覧で管理できる → 設計が明快

従来のNavigatorクラスに対するpushpopなどの命令的なルーティングと異なり、先に全ての遷移構造を定義し、その構造を元に画面遷移する宣言的なルーティングを行います。

Navigator 1.0は、遷移ロジックが各画面に散らばりやすく、アプリ全体の画面構成や状態を把握するのが困難です。

go_routerは、ルーティング定義が一箇所に集約されるため、設計が明快になります。

導入から基本的な遷移の実現まで

導入

まずはパッケージを追加します。

flutter pub add go_router

pubspec.yaml

dependencies:
  go_router: ^17.0.0

ルーティング定義(router/app_router.dart

アプリ全体の地図となるルーターを定義します。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../pages/home_page.dart';
import '../pages/list_page.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/', // アプリ起動時の初期パス
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const HomePage();
      },
    ),
    GoRoute(
      path: '/list',
      builder: (BuildContext context, GoRouterState state) {
        return const ListPage();
      },
    ),
  ],
);
「We don't recommend using named routes for most applications. 」
ほとんどのアプリケーションでは、名前付きルートの使用はお勧めしません。

https://docs.flutter.dev/ui/navigation#using-named-routes

go_routerをアプリに適用(main.dart)

MaterialApp のコンストラクタを MaterialApp.router に変更し、作成した routerConfig を渡します。

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: appRouter,
      // titleやthemeなどの設定は通常通り記述できます
    );
  }
}

遷移の実行: pushgo の使い分け

go_router で最も重要な概念の一つが、遷移メソッドの使い分けです。

同一サービス(機能)内ではpushメソッド、別サービス(エリア)への移動はgoメソッドを使います。

pushで掘り下げ(履歴を残す)

context.push(パス)は、遷移の履歴を保持し、ユーザーがアプリケーションを「掘り下げていく」動きを表現します。遷移後は左上に「←(戻るボタン)」が表示されます。

ElevatedButton(
  onPressed: () => context.push('/list'),
  child: const Text('push() to /list'),
),

popで1つ戻る

context.pop() は、ページ遷移の履歴を元に1つ前の画面に遷移します。

ElevatedButton(
  onPressed: () {
    context.pop();
  },
  child: Text('pop() to prev'),
),

goで切り替え(履歴をリセット)

context.go(パス) は、ページ遷移の履歴を置き換えリセットします。ユーザーがあるタスクを完了した、またはアプリケーションの主要な領域を移動したことを表現します。前の画面の履歴は完全に消えます。

ElevatedButton(
  onPressed: () => context.go('/list'),
  child: const Text('go() to /list'),
),

パスパラメータで詳細画面へ

動的なIDなどをURLに含めて受け渡す方法です。

ルーティング定義(router/app_router.dart

今回パスは/list/detail/:id に対応したルート定義をします。

final GoRouter appRouter = GoRouter(
  initialLocation: '/',
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const HomePage();
      },
    ),
    GoRoute(
      path: '/list',
      name: 'list',
      builder: (context, state) {
        return const ListPage();
      },
      routes: [
        GoRoute(
          path: 'detail/:id',
          builder: (context, state) {
            final id = int.tryParse(state.pathParameters['id']!) ?? 0;
            return DetailPage(id: id,);
          },
        ),
      ],
    ),
  ],
);

pushで遷移

同一サービス内での連続した操作のためgoではなくpushを使う方が好ましいです。

ElevatedButton(
  onPressed: () {
    final id = 999;
    context.push('/list/detail/$id');
  },
  child: Text('push() to /list/:id'),
),

パラメータの受け取り

パラメータはルーティング定義(app_router.dart)にて記述します。

state.pathParameters['パラメータ名'] から受け取ります。

以下は抜粋の上、app_router.dartを再掲しています。

GoRoute(
  path: 'detail/:id',
  builder: (context, state) {
    final id = int.tryParse(state.pathParameters['id']!) ?? 0;
    return DetailPage(id: id,);
  },
),

詳細ページのコンストラクタで値を受け取る

class DetailPage extends StatelessWidget {
  final int id;

  DetailPage({super.key, required this.id});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
以下省略

ネストしたナビゲーションでタブバー実装

アプリによくある「下部にナビゲーションバーがあり、タブを切り替えても状態が維持される」UIは、StatefulShellRoute を使用して実装します。

ナビバーのウィジェットを作成(ScaffoldWithNavBar)

このウィジェットは「額縁」の役割を果たします。go_router から渡される navigationShell(現在表示すべき中身)を body に表示します。

3つのポイント

  1. body: navigationShell
    • URL(パス)が変わるたびに、ここの中身だけが入れ替わります。
  2. currentIndex: navigationShell.currentIndex
    • URLが変わった時(例えばリンクで飛んだ時)に、下のタブの選択状態を自動で同期させるために必要です。
  3. goBranch(index)
    • 通常の go() ではなく、ブランチ(枝)を切り替えるメソッドを使います。
    • initialLocation: ... の行を入れることで、「ホームタブにいる時に、もう一度ホームアイコンを押すと一番上に戻る」という、よくある挙動を実現しています。

lib/layouts/scaffold_with_navigation.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class ScaffoldWithNavBar extends StatelessWidget {
  const ScaffoldWithNavBar({
    required this.navigationShell,
    super.key,
  });

  // go_routerから渡される「現在のナビゲーション状態」
  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ★重要: bodyにnavigationShellをそのまま渡します。
      // これにより、現在選択されているタブの中身(GoRouteのbuilder)が表示されます。
      body: navigationShell,

      bottomNavigationBar: BottomNavigationBar(
        // 1. 現在のアクティブなタブのインデックス
        currentIndex: navigationShell.currentIndex,
        
        // 2. タブの定義 (アイコンとラベル)
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: '検索'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'マイページ'),
        ],

        // 3. タブ切り替えロジック
        onTap: (int index) {
          navigationShell.goBranch(
            index,
            // 既にそのタブにいる状態でタップしたら、そのタブの最初(ルート)に戻る
            initialLocation: index == navigationShell.currentIndex,
          );
        },
      ),
    );
  }
}

公式サンプル

https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart

各タブ(機能ごとのページ)のウィジェットを作成

今回はシンプルなウィジェットを作成しています。ShellRouteとしての特別な書き方はありません。

  • 外枠(ScaffoldWithNavBar)にも Scaffold がありますが、中身のページにも Scaffold を使うのが一般的です。
  • これにより、各ページごとに固有の AppBar(タイトルやアクションボタン)や FloatingActionButton、背景色を設定できます。

lib/pages/my_page.dart

import 'package:flutter/material.dart';

class MyPage extends StatelessWidget {
  const MyPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('マイページ')),
      body: const Center(
        child: Text(
          '👤 マイページ',
          style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
        ),
      ),
    );
  }
}

StatefulShellRoute.indexedStackでタブ内ルート定義

  • StatefulShellRoute.indexedStack:
    • これが「状態を保持するタブシステム」全体を表します。
  • builder:
    • ここで ScaffoldWithNavBar(額縁)を呼び出しています。
  • branches:
    • ここに登録した順番(上から0, 1, 2...)が、ScaffoldWithNavBar 内の BottomNavigationBaritems の順番と対応します。
    • 重要: ここの順番とアイコンの順番がズレると、「ホームアイコンを押したのに検索画面が出る」といったバグになるので注意してください。
  • initialLocation: '/', としランディングページはナビバーなしのTopPageを表示しています。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:performance_flutter/layouts/scaffold_with_navigation.dart';

import 'package:performance_flutter/pages/detail_page.dart';
import 'package:performance_flutter/pages/my_page.dart';
import 'package:performance_flutter/pages/search_page.dart';
import 'package:performance_flutter/pages/top_page.dart';

import '../pages/home_page.dart';
import '../pages/list_page.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/',
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const TopPage();
      },
    ),
    GoRoute(
      path: '/list',
      name: 'list',
      builder: (context, state) {
        return const ListPage();
      },
      routes: [
        GoRoute(
          path: 'detail/:id',
          builder: (context, state) {
            final id = int.tryParse(state.pathParameters['id']!) ?? 0;
            return DetailPage(id: id);
          },
        ),
      ],
    ),
    // 状態を保持するShellRoute
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNavBar(navigationShell: navigationShell);
      },
      branches: [
        // --- タブ1: ホームの枝 ---
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => const HomePage(),
            ),
          ],
        ),
        // --- タブ2: 検索の枝 ---
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/search',
              builder: (context, state) => const SearchPage(),
            ),
          ],
        ),
        // --- タブ3: マイページの枝 ---
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/mypage',
              builder: (context, state) => const MyPage(),
            ),
          ],
        ),
      ],
    ),
  ],
);

参考

https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html

https://zenn.dev/flutteruniv_dev/articles/stateful_shell_route

おわりに

本記事では、Flutterの公式推奨ルーティングパッケージであるgo_routerについて、基礎的な導入からパラメータの受け渡し、そして多くの開発者が躓きやすいStatefulShellRouteを用いたネストされたナビゲーションの実装までを解説しました。

従来のNavigator 1.0と比較して、URLベースの宣言的なルーティングは、アプリの構造を可視化しやすく、ディープリンク対応やWeb展開を見据えた際にも強力な武器となります。特に、タブごとの状態保持(State Preservation)や、ルートナビゲーターを用いた「タブ内・外のルート共存」といった設計パターンは、中規模以上のアプリ開発においてユーザー体験を損なわないために必須の知識です。

最初はroutesの定義に記述量が多く感じるかもしれませんが、一度構造を設計してしまえば、あとはcontext.gocontext.pushといった直感的なメソッドで安全に画面遷移を管理できるようになります。

主要コードの全体

lib/router/app_router.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:performance_flutter/layouts/scaffold_with_navigation.dart';

import 'package:performance_flutter/pages/detail_page.dart';
import 'package:performance_flutter/pages/my_page.dart';
import 'package:performance_flutter/pages/search_page.dart';
import 'package:performance_flutter/pages/top_page.dart';

import '../pages/home_page.dart';
import '../pages/list_page.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/',
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const TopPage();
      },
    ),
    GoRoute(
      path: '/list',
      name: 'list',
      builder: (context, state) {
        return const ListPage();
      },
      routes: [
        GoRoute(
          path: 'detail/:id',
          builder: (context, state) {
            final id = int.tryParse(state.pathParameters['id']!) ?? 0;
            return DetailPage(id: id);
          },
        ),
      ],
    ),
    // 状態を保持するShellRoute
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNavBar(navigationShell: navigationShell);
      },
      branches: [
        // --- タブ1: ホームの枝 ---
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              builder: (context, state) => const HomePage(),
            ),
          ],
        ),
        // --- タブ2: 検索の枝 ---
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/search',
              builder: (context, state) => const SearchPage(),
            ),
          ],
        ),
        // --- タブ3: マイページの枝 ---
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/mypage',
              builder: (context, state) => const MyPage(),
            ),
          ],
        ),
      ],
    ),
  ],
);

lib/main.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:performance_flutter/http/fetch.dart';
import 'package:performance_flutter/model/data.dart';
import 'router/app_router.dart';

import 'sample_widget.dart';
import 'folder1/sample_class.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // return MaterialApp.router(routerConfig: appRouter,);
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          // テーマカラーを任意で変更 (: deepPurple)
          seedColor: Colors.deepPurple,
        ),
        // Material 3 を有効化
        useMaterial3: true,
      ),
      // ダークテーマもMaterial 3で設定
      darkTheme: ThemeData.dark(useMaterial3: true),
      // システム設定に応じてテーマを切り替え
      themeMode: ThemeMode.system,
      routerConfig: appRouter, // ここで GoRouter の設定を渡す
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  // This widget is the home page of your application. It is stateful, meaning
  // that it has a State object (defined below) that contains fields that affect
  // how it looks.

  // This class is the configuration for the state. It holds the values (in this
  // case the title) provided by the parent (in this case the App widget) and
  // used by the build method of the State. Fields in a Widget subclass are
  // always marked "final".

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      // This call to setState tells the Flutter framework that something has
      // changed in this State, which causes it to rerun the build method below
      // so that the display can reflect the updated values. If we changed
      // _counter without calling setState(), then the build method would not be
      // called again, and so nothing would appear to happen.
      _counter++;
      Sample(10).func1();
      Sample(10).tenYearsLayter;
    });
  }

  @override
  void initState() {
    super.initState();
    fetchItems().then((dataList) => print(dataList));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: () {
                context.go('/');
              },
              child: Text('/'),
            ),
            ElevatedButton(
              onPressed: () async {
                final newData = Data('999', 'some_title');
                await postData(newData);
              },
              child: Text('send'),
            ),
            SampleWidget(),
            const Text('You have pushed the button this many times:'),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

lib/layouts/scaffold_with_navigation.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class ScaffoldWithNavBar extends StatelessWidget {
  const ScaffoldWithNavBar({
    required this.navigationShell,
    super.key,
  });

  // go_routerから渡される「現在のナビゲーション状態」
  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ★重要: bodyにnavigationShellをそのまま渡します。
      // これにより、現在選択されているタブの中身(GoRouteのbuilder)が表示されます。
      body: navigationShell,

      bottomNavigationBar: BottomNavigationBar(
        // 1. 現在のアクティブなタブのインデックス
        currentIndex: navigationShell.currentIndex,
        
        // 2. タブの定義 (アイコンとラベル)
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: '検索'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'マイページ'),
        ],

        // 3. タブ切り替えロジック
        onTap: (int index) {
          navigationShell.goBranch(
            index,
            // 既にそのタブにいる状態でタップしたら、そのタブの最初(ルート)に戻る
            initialLocation: index == navigationShell.currentIndex,
          );
        },
      ),
    );
  }
}

Discussion