Shell Route와 StatefulShellRoute - [go_router]

Shell Route와 StatefulShellRoute - [go_router]

Tag
Flutter
최근 혼자 플러터를 이용해 개발을 좀 진행하고 싶어, 여러 플랫폼에서 사용할 수 있는 다이어리 앱을 만들어보고 있다.
웹을 사용한 프로젝트는 많이 진행해봤지만 아직 플러터만을 이용해서 프로젝트를 깊게 해본 적이 없어 진행하면서 겪었던 이슈들, 배웠던 점들을 정리해보려 한다.
 
오늘은 go_router를 공부하며 알게된 내용들을 정리하려 한다.
 

Routes

아래는 기존의 GoRouter 코드였다.
@Riverpod(keepAlive: true) GoRouter router(RouterRef ref) { return GoRouter( initialLocation: '/splash', routes: [ GoRoute( path: '/splash', builder: (context, state) => const SplashScreen(), ), GoRoute( path: '/', builder: (context, state) => const RootTabScreen(), ), ], ); }
SplashScreen widget에서 RootTabScreen으로 context.go(’/’) 함수를 호출하게 되면, 아래와 같이 TabBarView와 BottomNavigation을 이용하여 widget 내부에서 컨트롤 하는 방식이었다.
... class RootTabScreen extends StatefulWidget { const RootTabScreen({super.key}); @override State<RootTabScreen> createState() => _RootTabScreenState(); } class _RootTabScreenState extends State<RootTabScreen> with SingleTickerProviderStateMixin { int _currentIndex = 0; late TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(_tabListener); } @override void dispose() { _tabController.dispose(); super.dispose(); } void _tabListener() { setState(() { _currentIndex = _tabController.index; }); } @override Widget build(BuildContext context) { return DefaultLayout( body: TabBarView( physics: const NeverScrollableScrollPhysics(), controller: _tabController, children: const <Widget>[ TodayScreen(), CalendarScreen(), SearchScreen(), ProfileScreen(), ], ), bottomNavigation: BottomNavigationBar( currentIndex: _currentIndex, selectedFontSize: 10.0, unselectedFontSize: 10.0, type: BottomNavigationBarType.fixed, selectedItemColor: Colors.blue[400], unselectedItemColor: Colors.blue[100], onTap: (index) => _tabController.animateTo(index), items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.today), label: 'Today', ), BottomNavigationBarItem( icon: Icon(Icons.calendar_month), label: 'Calendar', ), BottomNavigationBarItem( icon: Icon(Icons.search), label: 'Search', ), BottomNavigationBarItem( icon: Icon(Icons.person), label: 'Profile', ), ], ), ); } }
 
하지만 이전의 개발들을 통해 느낀 점이라면, 나중의 딥링크 기능 구현을 위해서라면 현상태를 유지하면 안될 것이다.
예를 들어 딥링크를 통해 {SCHEME}://{HOST}/ 로 접근했다면, 쿼리를 통해 스크린을 분기하든가 해야하는데, 좀 짜친다..ㅎ
그렇기에 보통의 분기 방법을 찾아보니 ShellRoute 라는 것을 사용하는 듯 하다.
 

ShellRoute

... part 'route_provider.g.dart'; final _rootNavigatorKey = GlobalKey<NavigatorState>(); final _shellNavigatorKey = GlobalKey<NavigatorState>(); @Riverpod(keepAlive: true) GoRouter router(RouterRef ref) { return GoRouter( initialLocation: '/splash', navigatorKey: _rootNavigatorKey, routes: [ GoRoute( path: '/splash', builder: (context, state) => const SplashScreen(), ), ShellRoute( parentNavigatorKey: _rootNavigatorKey, navigatorKey: _shellNavigatorKey, routes: [ GoRoute( path: '/today', pageBuilder: (context, state) => const NoTransitionPage( child: TodayScreen(), ), ), GoRoute( path: '/calendar', pageBuilder: (context, state) => const NoTransitionPage( child: CalendarScreen(), ), ), GoRoute( path: '/search', pageBuilder: (context, state) => const NoTransitionPage( child: SearchScreen(), ), routes: [ GoRoute( path: 'calendar', parentNavigatorKey: _rootNavigatorKey, builder: (context, state) => const CalendarScreen(), ) ], ), GoRoute( path: '/profile', pageBuilder: (context, state) => const NoTransitionPage( child: ProfileScreen(), ), ), ], builder: (context, state, child) { return ScaffoldWithNav(child: child); }, ), ], ); }
이전과 달라진 점을 보자.
🔥
1. GoRoute가 아닌 ShellRoute가 쓰인 부분이 있다. 2. NavigatorKey가 두 개 쓰이고 있다. 3. builder가 아닌 pageBuilder가 쓰이고 있다.
간단히 생각하면 route의 세그먼트 분리를 하고 있다. 라고 생각하면 좋을 것 같다.
navigatorKey가 위의 _shellNavigatorKey로 세팅이 되어있다면, ScaffoldWithNav와 함께 build가 되며, 위의 /search/calendar 같은 경우는 _rootNavigatorKey가 세팅되어있으므로 ScaffoldWithNav 즉 BottomNavigation과 함께 렌더링되는 것이 아닌, 바로 렌더링 되는 것이다.

StatefulShellRoute

지금 내가 구현한 단계에선 사실 라우팅이 어렵지 않다.
하지만 앱이 복잡해지게 되면 BottomNavigation을 통해 접근 → 특정 스크린 내에서 탭바를 사용 등 네이버게이션이 복잡해지면 히스토리 스택이 복잡해질 수 있다.
그걸 git의 branch 개념처럼 브랜치 내의 스택 정보를 저장해줄 수 있는 방식이 바로 StatefulShellRoute 라고 한다.
 
... part 'route_provider.g.dart'; final _rootNavigatorKey = GlobalKey<NavigatorState>(); @Riverpod(keepAlive: true) GoRouter router(RouterRef ref) { return GoRouter( initialLocation: '/splash', navigatorKey: _rootNavigatorKey, routes: [ GoRoute( path: '/splash', builder: (context, state) => const SplashScreen(), ), StatefulShellRoute.indexedStack( parentNavigatorKey: _rootNavigatorKey, builder: (context, state, navigationShell) => ScaffoldWithNav( navigationShell: navigationShell, ), branches: <StatefulShellBranch>[ StatefulShellBranch( routes: [ GoRoute( path: '/today', pageBuilder: (context, state) => const NoTransitionPage( child: TodayScreen(), ), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: '/calendar', pageBuilder: (context, state) => const NoTransitionPage( child: CalendarScreen(), ), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: '/search', pageBuilder: (context, state) => const NoTransitionPage( child: SearchScreen(), ), routes: [ GoRoute( path: 'calendar', builder: (context, state) => const CalendarScreen(), ) ], ), ], ), StatefulShellBranch( routes: [ GoRoute( path: '/profile', pageBuilder: (context, state) => const NoTransitionPage( child: ProfileScreen(), ), ), ], ), ], ), ], ); }
// scaffold_with_nav.dart import 'package:diary/common/components/default_layout.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; const Map<int, String> NAV_INDEX_ENDPOINT_MAPPER = { 0: '/today', 1: '/calendar', 2: '/search', 3: '/profile' }; class ScaffoldWithNav extends StatefulWidget { final StatefulNavigationShell navigationShell; const ScaffoldWithNav({super.key, required this.navigationShell}); @override State<ScaffoldWithNav> createState() => _ScaffoldWithNavState(); } class _ScaffoldWithNavState extends State<ScaffoldWithNav> { int currentIndex = 0; void onTapBottomNavigation(int index) { final hasAlreadyOnBranch = index == widget.navigationShell.currentIndex; if (hasAlreadyOnBranch) { context.go(NAV_INDEX_ENDPOINT_MAPPER[index]!); } else { widget.navigationShell.goBranch(index); } } @override Widget build(BuildContext context) { _initNavigationIndex(context); return DefaultLayout( body: widget.navigationShell, bottomNavigation: BottomNavigationBar( currentIndex: currentIndex ?? 0, selectedFontSize: 10.0, unselectedFontSize: 10.0, type: BottomNavigationBarType.fixed, selectedItemColor: Colors.blue[400], unselectedItemColor: Colors.blue[100], onTap: onTapBottomNavigation, items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.today), label: 'Today', ), BottomNavigationBarItem( icon: Icon(Icons.calendar_month), label: 'Calendar', ), BottomNavigationBarItem( icon: Icon(Icons.search), label: 'Search', ), BottomNavigationBarItem( icon: Icon(Icons.person), label: 'Profile', ), ], ), ); } void _initNavigationIndex(BuildContext context) { final routerState = GoRouterState.of(context); late int index; for (final entry in NAV_INDEX_ENDPOINT_MAPPER.entries) { if (routerState.fullPath!.startsWith(entry.value)) { index = entry.key; } } setState(() => currentIndex = index); } }
 
ShellRoute와 같게 구현한 코드이다.
하지만 여기서 다른 점은
🔥
1. 브랜치마다의 navigation state를 보존해준다. ( 위에서 얘기한 내용 ) 2. branch마다 따로 navigatorKey가 별다르게 필요하지 않다.
 
위와 같이 구현을 하게 되면 /search 관련 브랜치가 따로 존재하기 때문에,
BottomNavigation을 탭하여 /search 관련 브랜치로 접근하게 되면 해당 스택 상태가 보존되어있음을 알 수 있다.
 

References