Flutter: ナビゲーションとルーティング

ルートはアプリの地図です — なければ、ユーザーはGPSのないドライバーのように、どこにも行けません。

📋 前提条件: 以下を先にマスターしている必要があります

1. このレッスンで学ぶこと


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宣言型ルーティング

100%
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を設定します。

📖 まとめ


📝 練習問題

  1. 基本(⭐): GoRouterを使って3つのルート(Home/Cart/Profile)を設定し、下部ナビゲーションで切り替えてください。
  2. 中級(⭐⭐): /product/:idの詳細ルートを追加し、ホームページの商品カードからナビゲーションし、パスパラメータの渡しに対応してください。
  3. チャレンジ(⭐⭐⭐): 完全なルートガードを実装してください:未認証ユーザーが/profileにアクセスしたら/loginにリダイレクトし、ログイン後に自動的に元のページにリダイレクト。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%