
요즘 모바일 앱에는 bottom navigation이 포함되어 있는 경우가 많다. 인스타그램의 [홈 / 릴스 / 메시지 / 탐색 / 프로필]도 그렇고 브런치스토리의 [홈 / 발견 / 독서클럽 / 구독 / 내 서랍]이라거나 텔레그램의 [대화 / 연락처 / 설정 / 프로필]처럼 말이다. 물론 모든 모바일 앱이 이런 형태인 건 아니고 디스코드처럼 bottom navigation이 없는 형태의 앱도 얼마든지 있을 수 있다.
Flutter에서는 bottom navigation을 구현할 때 StatefulShellRoute 를 사용한다. 다른 방식으로도 구현할 수 있긴 하지만 이게 가장 효율적이기 때문에 굳이 다른 방식을 고집할 이유가 없다. StatefulShellRoute 는 Flutter의 사실상 표준 routing package인 go_router package에 포함되어 있다.
go_router packageFlutter의 기본적인 navigation 시스템은 Stack 구조를 기반으로 작동한다. 전통적인 방식의 앱 개발에서는 Navigator.push() 와 Navigator.pop() 을 이용하여 하나의 navigator 위에서 widget을 쌓고 빼는 동작을 수행한다. 그런데 앱의 규모가 커지고, 특히 웹 지원이나 deep link 같은 요구사항이 추가되는 순간 이러한 방식은 꼬이기 쉽다. 같은 화면에 도달하는 다양한 방법이 있다면 앱의 현재 네비게이션 상태를 파악할 수 없게 되며, 특정 페이지에 도달하기 위해 필요 이상으로 많은 화면을 밑에 깔아야 할 수도 있다.
이러한 명령형 방식의 단점을 보완하기 위해 선언형으로 접근하고자 하여 대두된 게 go_router package다. 이 녀석은 특정 path에 어떤 widget이 매핑되는지 사전에 정의해 둔다. context.go('path_url') 을 통해 사전 정의된 path에 접근하면 내부적으로 navigation stack을 알아서 재조정하기 때문에 사용자가 어떤 경로로 그 페이지에 도달하든 동일한 path와 stack을 갖는다. 또한 화면을 쌓지 않고 원하는 페이지에 바로 접근하는 deep link를 구현하고자 할 때도 이 path를 통해 쉽게 구현 가능하다.
그런데 명령형 방식에서는 사용자의 행동에 의해 UI flow가 결정되므로 bottom navigation에서 선택하는 것에 따라 내용물을 바꿔 끼우기가 수월했는데, 선언형 방식의 경우 부분적으로 바꿔 끼우는 게 아니라 path가 바뀌면 모든 것을 새로 그리게 되어 공통적인 부분에 대한 구현 비효율이 발생했다. 이를 보완하기 위해 나온 게 ShellRoute 다.
ShellRouteShell, 그러니까 껍데기다. 이 껍데기에 공통 UI를 구현해 놓고 내용물을 바꿔 끼우기 위한 widget이다. ShellRoute 를 사용하면 bottom navigation이 정의된 Scaffold 를 껍데기로 사용하며 body 의 내용물을 바꿔 끼울 수 있다. 현재 bottom navigation에 어떤 탭이 활성화되어 있는지에 대한 state를 따로 가진 채 선언형 path에 따라 화면을 그릴 수 있게 된 것이다.
하지만 이걸로도 완전하지 않다. 이대로는 홈 화면에서 피드를 살펴보다가 검색 탭으로 이동하여 무언가 찾아본 후 다시 홈 화면으로 돌아왔을 때 기존에 보던 화면에 대한 정보가 완전히 사라지고 화면이 새로 그려진다. 많은 경우 사용자는 원래 보던 걸 마저 보고 싶어하는데 말이다. (사람을 짜증나게 하는 방법에는 두 가지가 있는데 첫 번째는 말을 하다 마는 것이고)
이는 ShellRoute 가 bottom navigation의 각 탭에 있는 하위 route를 관리할 때 단일 navigator를 사용하기 때문에 발생하는 일이다. 탭을 이동할 때마다 기존 탭의 route를 제거하고 새 탭의 route를 담기 때문에 기존 탭에 대한 state가 완전히 사라진다.
이를 보완하기 위해 각 탭이 독립적인 history를 가지도록 개별 navigator를 사용하는 방식을 채택한 게 StatefulShellRoute 다.
ShellRoute 아니고 StatefulShellRouteStatefulShellRoute 는 각 탭(정확히는 branch 라고 한다)이 서로 다른 독립적인 Navigator object를 갖도록 구현한다. 이로써 운영체제의 multi-thread 프로그래밍에서 각 thread가 고유의 call stack을 가지는 것과 마찬가지로 각 탭은 고유의 navigation stack과 NavigatorState 를 갖는다.
StatefulShellRoute 는 두 가지 핵심 component를 통해 각 navigator의 병렬 구조를 제어한다.
StatefulShellBranchStatefulShellBranch 는 navigation tree의 한 갈래를 나타낸다. 간단히 말해 bottom navigation의 탭 하나당 하나의 StatefulShellBranch 가 존재하는 것이다. StatefulShellRoute 의 branches property가 List<StatefulShellBranch 다. 각 branch는 고유한 GlobalKey<NavigatorState> 를 가지며 독자적인 routing stack을 관리한다.
GlobalKey<NavigatorState> 는 각 StatefulShellBranch 의 navigatorKey 에 서로 다른 object으로 하나씩 할당해야 한다. 이때, 최상위 GoRouter 에도 navigatorKey 를 할당해야 함을 유의하자. 이것을 빼먹으면 key가 꼬여서 제대로 작동하지 않는다.
StatefulNavigationShellStatefulShellRoute 의 builder property에 전달되는 callback function의 세 번째 parameter가 바로 StatefulNavigationShell 이다. 공통 UI를 담는 껍데기로 사용할 widget에 반드시 포함되어 있어야 한다.
builder: (context, state, navigationShell) => BottomNavShell(navigationShell: navigationShell),
StatefulNavigationShell 은 각각의 branch navigator를 감싸고 관리하는 container다. 현재 활성화된 index를 추적하고 goBranch() method를 통해 branch 간의 전환을 수행한다. 운영체제에서 CPU가 실행 중인 process를 전환할 때 state를 저장하고 복원하는 context switching과 비슷한 역할을 한다고 볼 수 있겠다.
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class BottomNavShell extends StatelessWidget { final StatefulNavigationShell _navigationShell; const BottomNavShell({super.key, required this._navigationShell}); Widget build(BuildContext context) { return Scaffold( body: _navigationShell, bottomNavigationBar: NavigationBar( selectedIndex: _navigationShell.currentIndex, destinations: [ NavigationDestination( icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'home', ), NavigationDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'settings', ), ], onDestinationSelected: (index) { _navigationShell.goBranch( index, initialLocation: index == _navigationShell.currentIndex, ); }, ), ); } }
공통 UI를 담는 껍데기로 사용하는 widget이 생성될 때 constructor에서 StatefulNavigationShell object를 주입받고, 그것을 그대로 body 에 넣어 알맹이로 사용한다.
그러는 동시에, bottom navigation에서 현재 활성화된 탭을 나타내는 selectedIndex 에 StatefulNavigationShell object가 가진 currentIndex 값을 넣어 적절한 탭이 활성화되도록 한다.
bottom navigation을 클릭하여 다른 탭으로 넘어가고자 할 때도 onDestinationSelected 에 전달된 callback function에서 StatefulNavigationShell 의 method인 goBranch 를 사용한다.
이때, callback function의 parameter로 전달되는 목적지 index와 StatefulNavigationShell 가 가진 currentIndex 를 비교하여 현재 탭을 클릭했을 경우 해당 route의 root로 이동하도록 구현할 수 있다.
Offstage일반적으로 화면에서 widget이 사라지면 프레임워크는 Garbage Collector를 사용하여 해당 widget을 rendering tree에서 unmount한다. 그렇게 되면 기껏 독립적인 navigator를 사용해도 이전 탭을 유지하지 못하게 될 수 있다. 따라서 StatefulShellRoute 는 당장 화면에 존재하지 않는 branch에 대해서 rendering tree에서 소멸되지 않게 하기 위해 Offstage 를 사용한다.
go_router 가 기본적으로 제공하는 StatefulShellRoute.indexedStack factory constructor를 이용하면 이를 쉽게 구현할 수 있다. IndexedStack widget은 내부적으로 자식 widget들을 rendering tree에 유지한 채, 현재 활성화되지 않은 자식 widget들을 Offstage widget으로 감싼다.
Offstage 로 감싸진 widget들은 다른 widget과 마찬가지로 build 된 채 메모리에 상주하고 있지만 공간을 차지하지 않는 것처럼 처리되며, 화면에 그려지는 paint 연산이 생략되고 사용자 입력에 대한 이벤트 수신이 비활성화된다. 화면을 전환할 땐 메모리에 상주 중인 녀석을 다시 build 하지 않고 paint만 새로 해주는 것이다.
StatefulShellRoute 를 사용할 땐 숨겨진 모든 탭의 instance를 들고 있어 메모리 점유율 상승할 수 있음을 유의하도록 하자. 요즘 기기 성능이 많이 좋아져 웬만해서는 StatefulShellRoute 를 사용할 때의 이점이 메모리 점유율 상승이라는 단점을 충분히 커버할 수 있을 것으로 판단되긴 한다.
사용자는 앱을 사용할 때 내부의 복잡한 구조를 알지 못한 채 탭을 오갈 때의 UX만을 가지고 앱의 완성도를 판단한다. StatefulShellRoute 를 통해 bottom navigation을 구현하면 이러한 UX적인 완성도를 높일 수 있다.