mcpField
F
flutter-navigation-go-router

flutter-navigation-go-router

github.com/junsuk5開発2451 回インストール24 週間前に更新
リポジトリ

flutter-navigation-go-router について

flutter-navigation-go-router は、go_router を使った Flutter ナビゲーション構成を支援するスキルです。パスの一元管理、型付きルートパラメーター、シェルによるタブ遷移、コールバックを介した画面の責務分離に関する規約を示します。

主な機能

  1. 01ルートパスを定数に集約
  2. 02単一のルーターでルートを定義
  3. 03ルートウィジェット作成前にパラメーターを解析
  4. 04スタック置換とプッシュを使い分け
  5. 05タブごとに独立したスタックを保持
  6. 06コールバックでシンプルな画面を分離

活用シーン

  1. 01型付きルートパラメーターを持つ画面の追加
  2. 02独立したスタックを保持するタブの実装
  3. 03画面操作とナビゲーション効果の分離
  4. 04シンプルな画面のテスト容易化

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/junsuk5/survival-flutter-skills --skill flutter-navigation-go-router

インストール後、Claude Code で/flutter-navigation-go-routerを入力して読み込まれたか確認します。

README(原文)English

プロジェクトの README より

この README の日本語訳はまだありません。以下は英語の原文です。日本語の紹介はページ上部をご覧ください。

Flutter Navigation — go_router

원칙

  • 경로 상수는 한 곳에 모은다. lib/core/routing/route_paths.dart 의 RoutePaths 에 문자열 상수로 선언한다. 코드 어디에서도 raw 문자열을 직접 쓰지 않는다.
  • 라우터 정의는 lib/core/routing/router.dart 하나. GoRouter 인스턴스는 전역 router 변수로 노출되고 main.dart 의 MaterialApp.router 가 소비한다.
  • 라우트 빌더는 Root 위젯만 만든다. DI/이벤트/상태 구독은 Root 가 담당하므로 라우터는 Screen 에 대한 지식이 없다.
  • 크로스 feature 네비게이션은 context.go / context.push 콜백으로 처리한다. Screen/Root 가 다른 feature 의 라우트 문자열을 알아도 된다는 것이 이 프로젝트의 관례다 — RoutePaths 상수를 통해서 참조하기 때문에 안전하다.

경로 상수

// lib/core/routing/route_paths.dart
abstract class RoutePaths {
  static const String splash = '/Splash';
  static const String signIn = '/SingIn';
  static const String signUp = '/SingUp';

  static const String home = '/Home';
  static const String savedRecipes = '/SavedRecipes';
  static const String notifications = '/Notifications';
  static const String profile = '/Profile';

  static const String search = '/Home/Search';
  static const String ingredient = '/Home/Ingredient/:recipeId';
}

규칙:

  • 최상위 화면은 /<Name> 형태.
  • 중첩(서브) 경로는 상위 경로를 접두어로 포함 (/Home/Ingredient/:recipeId).
  • 경로 파라미터는 :name 토큰으로.

GoRouter 정의

// lib/core/routing/router.dart
final router = GoRouter(
  initialLocation: RoutePaths.splash,
  routes: [
    GoRoute(
      path: RoutePaths.ingredient,
      builder: (context, state) {
        final recipeId = int.parse(state.pathParameters['recipeId']!);
        return IngredientRoot(recipeId: recipeId);
      },
    ),
    GoRoute(
      path: RoutePaths.splash,
      builder: (context, state) => SplashScreen(
        onTapStartCooking: () => context.go(RoutePaths.signIn),
      ),
    ),
    // ... 기타 라우트
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) => MainScreen(
        body: navigationShell,
        currentPageIndex: navigationShell.currentIndex,
        onChangeIndex: (index) => navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        ),
      ),
      branches: [
        StatefulShellBranch(routes: [
          GoRoute(path: RoutePaths.home, builder: (c, s) => const HomeRoot()),
        ]),
        StatefulShellBranch(routes: [
          GoRoute(path: RoutePaths.savedRecipes, builder: (c, s) => const SavedRecipesRoot()),
        ]),
        // notifications, profile ...
      ],
    ),
  ],
);
// lib/main.dart
return MaterialApp.router(
  routerConfig: router,
  // ...
);

경로 파라미터 읽기

파라미터는 state.pathParameters 에서 문자열로 나온다. 타입 변환은 builder 안에서 수행해 Root 위젯에는 타입이 맞춰진 값만 전달한다.

GoRoute(
  path: RoutePaths.ingredient, // '/Home/Ingredient/:recipeId'
  builder: (context, state) {
    final recipeId = int.parse(state.pathParameters['recipeId']!);
    return IngredientRoot(recipeId: recipeId);
  },
),

Root 위젯 시그니처:

class IngredientRoot extends StatelessWidget {
  final int recipeId;
  const IngredientRoot({super.key, required this.recipeId});
}

IngredientRoot 는 내부에서 getIt<IngredientViewModel>() 로 ViewModel 을 꺼내고 loadRecipe(recipeId) 를 디스패치한다.


화면 이동

context.go vs context.push
  • context.go(path) — 스택을 교체한다. 로그인 → 홈처럼 돌아갈 필요가 없을 때.
  • context.push(path) — 스택에 쌓는다. 디테일 화면처럼 돌아갈 수 있어야 할 때.
// 교체: splash → sign in
context.go(RoutePaths.signIn);

// 푸시: 레시피 상세로 진입
context.push('/Home/Ingredient/${recipe.id}');

경로에 파라미터가 있으면 보간으로 만든다. 상수 외의 부분을 문자열로 조립해도 상위 경로 상수는 유지하는 것이 좋다.

Root 위젯에서 쓰는 전형적 패턴

Root 위젯이 Action 을 받아 적절히 분기한다 (saved_recipes_root.dart 참고):

onAction: (action) {
  if (action is OnTapRecipe) {
    context.push('/Home/Ingredient/${action.recipe.id}');
    return;
  }
  viewModel.onAction(action);
},

즉 상태를 바꾸는 Action 은 ViewModel 로, 이동이라는 효과는 context.push 로 분리한다.


바텀 내비게이션 — StatefulShellRoute

각 탭이 자기 스택을 유지해야 할 때 StatefulShellRoute.indexedStack 을 쓴다. navigationShell.goBranch(index, initialLocation: ...) 로 탭을 전환한다. initialLocation: index == currentIndex 로 두면 같은 탭을 다시 눌렀을 때 루트로 돌아가는 동작이 구현된다.

StatefulShellRoute.indexedStack(
  builder: (context, state, navigationShell) => MainScreen(
    body: navigationShell,
    currentPageIndex: navigationShell.currentIndex,
    onChangeIndex: (index) => navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    ),
  ),
  branches: [ /* 탭마다 StatefulShellBranch */ ],
)

콜백을 통해 느슨하게 결합하기

SplashScreen, SignInScreen, SignUpScreen 같은 단순 화면은 ViewModel 없이 콜백만 받는 형태로 구성된다. 라우터가 colback 을 주입해 Screen 이 경로 상수를 몰라도 되게 한다.

GoRoute(
  path: RoutePaths.signUp,
  builder: (context, state) => SignUpScreen(
    onTapSignIn: () => context.go(RoutePaths.signIn),
  ),
),

이 방식은 테스트 가능한 Screen 을 만드는 핵심이다. Screen 이 go_router 를 직접 import 하지 않는다.


체크리스트 — 새 화면 라우트 추가

  • RoutePaths 에 경로 상수 추가
  • router.dart 의 적절한 위치(탭 내부라면 StatefulShellBranch 안)에 GoRoute 추가
  • builder 가 파라미터를 파싱해 Root 위젯에 전달
  • Root 위젯 생성자는 파라미터를 타입 안전하게 받음
  • 이동이 필요한 호출자는 context.go / context.push + RoutePaths 상수 사용
  • 단순 화면은 Screen 파라미터로 콜백(onTap...)을 받아 느슨하게 결합

안티 패턴

  • ❌ 경로 문자열을 화면 곳곳에 하드코딩 → RoutePaths 로 모아라.
  • ❌ ViewModel 안에서 context.go(...) 호출 → ViewModel 은 BuildContext 를 모른다.
  • ❌ Screen 에서 go_router 를 직접 import → 콜백 파라미터로 받게 바꿔라.
  • ❌ 서로 다른 feature 간 이동을 위해 Navigator.of(context).push(MaterialPageRoute(...)) 를 섞어 쓰기 → 일관된 go_router API 하나만 쓴다.
  • ❌ 경로 파라미터 state.pathParameters['id'] 에서 ! 없이 기본값을 걸어 에러를 가림 → 파라미터가 필수라면 int.parse(state.pathParameters['recipeId']!) 처럼 강제 언래핑해 라우트 정의 오류가 즉시 드러나게 하라.

コメント