Flutter: ナビゲーションとルーティング
ルートはアプリの地図です — なければ、ユーザーはGPSのないドライバーのように、どこにも行けません。
📋 前提条件: 以下を先にマスターしている必要があります
- レッスン6:Material Designと共通コンポーネント
- レッスン7:第1フェーズ演習 — ShopAppスターター
1. このレッスンで学ぶこと
- Navigator 1.0:push/pop/pushReplacementとルートスタック管理
- 名前付きルート:ルートテーブル / onGenerateRoute / パラメータ渡し
- GoRouter(Navigator 2.0):宣言型ルーティング、ネストされたShellRoute、リダイレクト
- ディープリンクとURLストラテジー
- ShopApp GoRouterルーティングシステム:/home /product/:id /cart /checkout
2. 迷子ページのリアルなストーリー
(1) 悩み:ルートスタックの混乱
BobのShopAppには10のページがあり、すべてNavigator.pushでナビゲーションしています。ユーザーがHome → Search → Product → Cart → Checkoutと進み、戻るボタンを押すとHomeではなくSearchに戻ります。ルートスタックには5つのページがあり、Homeに到達するには4回戻る必要があります。さらに、WebのURLが変更されず、リフレッシュですべての状態が失われます。
(2) GoRouterのソリューション
GoRouterは宣言型ルーティングを使用し、URLとページが自動的にマッピングされ、ルートスタックは設定によって決まります。
⚙️ 依存パッケージのインストール:
flutter pub add go_router
DART
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Declarative routing: URL ↔ Page mapping
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/product/:id', builder: (_, state) => DetailPage(id: state.pathParameters['id'])),
GoRoute(path: '/cart', builder: (_, __) => const CartPage()),
],
);
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) メリット:URL駆動ナビゲーション + 自動バックロジック
GoRouterを使用後、WebのURLはページ変更とともに更新され、リフレッシュでも状態が保持されます。Androidの戻るボタンはルートスタックに自動的に従い、チェックアウト後はgo('/')でHomeに直接戻れます。
3. Navigator 1.0の基礎
(1) ルートスタック操作
| メソッド | 効果 | スタックの変化 |
|---|---|---|
push |
新しいページをプッシュ | [A] → [A, B] |
pop |
現在のページをポップ | [A, B] → [A] |
pushReplacement |
現在のページを置換 | [A, B] → [A, C] |
pushAndRemoveUntil |
プッシュしてターゲットページまでクリア | [A,B,C] → [A, D] |
popUntil |
ターゲットページまでポップ | [A,B,C] → [A] |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 基本的なナビゲーション操作
DART
import 'package:flutter/material.dart';
// Simplified class definitions
class Product {
const Product();
}
class DetailPage extends StatelessWidget {
final Product product;
const DetailPage({super.key, required this.product});
@override
Widget build(BuildContext context) => const Scaffold();
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) => const Scaffold();
}
// Push: navigate to detail page
Navigator.push(
context,
MaterialPageRoute(builder: (_) => DetailPage(product: product)),
);
// Pop: go back with result
Navigator.pop(context, result);
// Push replacement: login → home (no back to login)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const HomePage()),
);
// Push and remove until: checkout → home (clear all intermediate)
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const HomePage()),
(route) => route.isFirst,
);
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
4. 名前付きルート
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 名前付きルートの設定
DART
import 'package:flutter/material.dart';
// Simplified page class definitions
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class DetailPage extends StatelessWidget {
final int id;
const DetailPage({super.key, required this.id});
@override Widget build(BuildContext context) => const Scaffold();
}
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const HomePage(),
'/cart': (context) => const CartPage(),
'/checkout': (context) => const CheckoutPage(),
},
onGenerateRoute: (settings) {
// Handle dynamic routes like /product/123
final uri = Uri.parse(settings.name!);
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'product') {
final id = int.parse(uri.pathSegments[1]);
return MaterialPageRoute(builder: (_) => DetailPage(id: id));
}
return null;
},
)
// Navigate with named route
Navigator.pushNamed(context, '/product/42');
Navigator.pushNamed(context, '/cart', arguments: {'source': 'detail'});
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| アプローチ | メリット | デメリット |
|---|---|---|
| 直接push | シンプル、直感的、型安全 | ルートが散在、保守が困難 |
| 名前付きルート | 一元管理、パラメータ渡し | パラメータが型安全でない |
| GoRouter | 宣言型、Web対応、ディープリンク | 追加の依存パッケージが必要 |
5. GoRouter宣言型ルーティング
graph TD
GR[GoRouter] --> R1["/ → HomePage"]
GR --> R2["/product/:id → DetailPage"]
GR --> R3["/cart → CartPage"]
GR --> R4["/checkout → CheckoutPage"]
R1 --> |DeepLink| R2
R2 --> |addToCart| R3
R3 --> |checkout| R4
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(1) GoRouterの中核概念
| 概念 | 説明 | 例 |
|---|---|---|
GoRoute |
ルート宣言 | GoRoute(path: '/cart') |
pathParameters |
パスパラメータ | /product/:id → {'id': '42'} |
queryParams |
クエリパラメータ | ?sort=price → {'sort': 'price'} |
redirect |
リダイレクトロジック | 未ログイン → /login |
ShellRoute |
ネストされたレイアウト | BottomNavを共有するサブルート |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: ShopApp GoRouter設定
DART
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Simplified class definitions
class AuthService {
static bool isLoggedIn = false;
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class DetailPage extends StatelessWidget {
final int productId;
const DetailPage({super.key, required this.productId});
@override Widget build(BuildContext context) => const Scaffold();
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
final router = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isLoggedIn = AuthService.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoginRoute) return '/login';
if (isLoggedIn && isLoginRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/product/:id',
name: 'product',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return DetailPage(productId: id);
},
),
GoRoute(
path: '/cart',
name: 'cart',
builder: (context, state) => const CartPage(),
),
GoRoute(
path: '/checkout',
name: 'checkout',
builder: (context, state) => const CheckoutPage(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
],
);
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(2) ShellRouteネストレイアウト
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: ShellRouteとBottomNav
DART
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Simplified page class definitions
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CategoriesPage extends StatelessWidget {
const CategoriesPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
class DetailPage extends StatelessWidget {
final int id;
const DetailPage({super.key, required this.id});
@override Widget build(BuildContext context) => const Scaffold();
}
class CheckoutPage extends StatelessWidget {
const CheckoutPage({super.key});
@override Widget build(BuildContext context) => const Scaffold();
}
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => ScaffoldWithNavBar(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/categories', builder: (_, __) => const CategoriesPage()),
GoRoute(path: '/cart', builder: (_, __) => const CartPage()),
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
],
),
GoRoute(path: '/product/:id', builder: (_, state) => DetailPage(
id: int.parse(state.pathParameters['id']!)),
),
GoRoute(path: '/checkout', builder: (_, __) => const CheckoutPage()),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex(context),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.category), label: 'Categories'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
onDestinationSelected: (index) => _onItemTapped(index, context),
),
);
}
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/categories')) return 1;
if (location.startsWith('/cart')) return 2;
if (location.startsWith('/profile')) return 3;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/');
case 1: context.go('/categories');
case 2: context.go('/cart');
case 3: context.go('/profile');
}
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. ナビゲーション方式の比較
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: go vs push vs replace
DART
import 'package:go_router/go_router.dart';
// go: replace entire stack (Web: URL changes)
context.go('/cart'); // Stack becomes [/cart]
// push: add to stack (Web: URL changes)
context.push('/product/42'); // Stack becomes [/, /product/42]
// replace: replace current route
context.replace('/checkout'); // Stack becomes [/, /checkout]
// go with named route
context.goNamed('product', pathParameters: {'id': '42'});
// go with query parameters
context.go('/products', queryParams: {'sort': 'price', 'order': 'desc'});
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| メソッド | スタック操作 | URL更新 | 用途 |
|---|---|---|---|
go |
スタック全体を置換 | あり | 主要ナビゲーション(タブ切り替え) |
push |
新しいルートをプッシュ | あり | 詳細ページナビゲーション |
replace |
現在のルートを置換 | あり | ログイン → ホーム |
7. 完成例:ShopAppルーティングシステム
⚙️ 依存パッケージのインストール:
flutter pub add go_router
DART
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() => runApp(ShopApp(router: router));
final router = GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) => MainScaffold(child: child),
routes: [
GoRoute(path: '/', name: 'home', builder: (_, __) => const HomePage()),
GoRoute(path: '/categories', builder: (_, __) => const CategoriesPage()),
GoRoute(path: '/cart', name: 'cart', builder: (_, __) => const CartPage()),
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
],
),
GoRoute(
path: '/product/:id',
name: 'product',
builder: (_, state) => DetailPage(id: int.parse(state.pathParameters['id']!)),
),
GoRoute(
path: '/checkout',
name: 'checkout',
builder: (_, __) => const CheckoutPage(),
),
],
errorBuilder: (_, state) => Scaffold(
body: Center(child: Text('Page not found: ${state.error}')),
),
);
class ShopApp extends StatelessWidget {
final GoRouter router;
const ShopApp({super.key, required this.router});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'ShopApp',
routerConfig: router,
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
);
}
}
class MainScaffold extends StatelessWidget {
final Widget child;
const MainScaffold({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calcIndex(context),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.category), label: 'Categories'),
NavigationDestination(icon: Icon(Icons.shopping_cart), label: 'Cart'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
onDestinationSelected: (i) {
final paths = ['/', '/categories', '/cart', '/profile'];
context.go(paths[i]);
},
),
);
}
int _calcIndex(BuildContext context) {
final loc = GoRouterState.of(context).matchedLocation;
if (loc.startsWith('/categories')) return 1;
if (loc.startsWith('/cart')) return 2;
if (loc.startsWith('/profile')) return 3;
return 0;
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q GoRouterとNavigator 1.0は混用できますか?
A 混用は推奨されません。GoRouterは内部的にNavigatorを管理しており、混用するとルートスタックの不整合が発生します。
Q ShellRoute内のサブルートでBottomNavを非表示にするには?
A BottomNavが不要なルート(詳細ページやチェックアウトページなど)をShellRouteの外に配置してください。
Q GoRouterでルートガード(認証)を実装するには?
A
redirectコールバックを使用してください:ログイン状態を確認し、未ログインなら/loginにリダイレクト。Q Webページの状態がリフレッシュで失われるのは?
A GoRouterはデフォルトでURL状態の復元をサポートしています。
MaterialAppではなくMaterialApp.routerを使用していることを確認してください。Q pushとgoの違いは?
A pushは新しいルートをスタックに積みます。goはスタック全体を置換します。タブナビゲーションにはgoを、詳細ページナビゲーションにはpushを使用してください。
Q iOSでディープリンクを設定するには?
A Associated Domainsと
apple-app-site-associationファイルの設定が必要です。AndroidではAsset Linksとintent-filterを設定します。📖 まとめ
- Navigator 1.0はpush/popでルートスタックを管理し、シンプルなシナリオに適している
- 名前付きルートはパス管理を一元化するが、パラメータは型安全ではない
- GoRouter宣言型ルーティング:URL ↔ ページ自動マッピング、Web対応
- ShellRouteは共有レイアウト(BottomNav)とネストされたサブルートを実現
- redirectはルートガードを実装、go/push/replaceがスタック操作を制御
📝 練習問題
- 基本(⭐): GoRouterを使って3つのルート(Home/Cart/Profile)を設定し、下部ナビゲーションで切り替えてください。
- 中級(⭐⭐):
/product/:idの詳細ルートを追加し、ホームページの商品カードからナビゲーションし、パスパラメータの渡しに対応してください。 - チャレンジ(⭐⭐⭐): 完全なルートガードを実装してください:未認証ユーザーが
/profileにアクセスしたら/loginにリダイレクトし、ログイン後に自動的に元のページにリダイレクト。