indextrown/turbonavigator
[한국어](./README.md) | [English](./README.en.md)
지원 환경
- 최소 지원 버전:
iOS 13 - UI 작성:
SwiftUI - navigation 엔진:
UIKit
왜 UIKit 엔진 기반인가
NavigationStack은 선언형 화면 경로 표현에는 좋지만, 실제 앱에서 자주 필요한 아래 흐름을 한 곳에서 운영하기에는 불편한 지점이 있다.
- 현재 push 대상이 root stack인지, tab stack인지, modal 위인지 매번 의식해야 한다.
backTo,backOrPush,replace, 동일 탭 재선택 시 root 복귀 같은 imperative 제어가 앱 구조 곳곳에 흩어지기 쉽다.stack,tab,modal,deep link마다 호출 방식이 달라 한 흐름으로 묶어 다루기 어렵다.- SwiftUI 화면은 잘 작성되더라도, 복잡한 화면 전환은 결국 별도로 정리된 제어 계층이 필요해진다.
TurboNavigator는 이 지점을 해결하기 위해 화면은 SwiftUI로 만들고, 실제 navigation 엔진은 UIKit으로 두었다.
특히 아래처럼 NavigationStack만으로는 다루기 까다롭거나 일관되게 통제하기 어려운 요구를 염두에 두고 설계했다.
- 시스템 전환 동작을 제어해야 할 때
- 예: iOS 18의 기본 탭 전환 애니메이션을 끄고 싶을 때처럼 UITabBarController, UINavigationController 레벨의 동작 제어가 필요할 수 있다.
- 현재 루트 스택 자체를 교체하거나 재구성해야 할 때
- 예: 로그인 완료 후 auth 플로우를 버리고 메인 플로우로 갈아타기, deep link 진입 시 [.home, .detail(id: ...)]처럼 스택을 새로 구성하기.
- 어느 스택이 현재 활성 대상인지 런타임에 바뀌는 앱일 때
- 예: root stack, tab stack, modal stack 중 어디에 push / back / replace를 적용해야 하는지 호출부가 직접 알지 않도록 하고 싶을 수 있다.
- route 기준으로 imperative 제어가 필요할 때
- 예: 특정 화면이 있으면 backTo, 없으면 backOrPush, 현재 stack을 currentRoutes()로 점검하는 흐름은 UIKit stack을 직접 다루는 쪽이 단순하다.
- SwiftUI와 UIKit을 섞어 써야 할 때
- 예: 일부 화면은 SwiftUI, 일부는 기존 UIViewController인 앱에서도 같은 navigator API를 유지하고 싶을 수 있다.
- 한 액션으로 여러 화면을 연속 구성해야 할 때
- 예: onboarding 종료 후 [.home, .promotion, .detail(id: ...)]를 한 번에 쌓거나, modal 안에 여러 route를 미리 구성해 진입하고 싶을 수 있다.
- 화면 인스턴스보다 route를 source of truth로 두고 싶을 때
- 예: 현재 스택을 UIViewController 참조가 아니라 route 배열 관점에서 읽고, 비교하고, 복원하는 흐름이 더 중요할 수 있다.
- 탭, 모달, 스택을 섞은 imperative 플로우를 한 API로 유지하고 싶을 때
- 예: “현재 modal이 떠 있으면 modal에 push, 아니면 현재 선택된 tab stack에 push” 같은 규칙을 화면 코드가 직접 풀지 않게 만들고 싶을 수 있다.
- SwiftUI 기본 네비게이션 상태와 화면 생명주기를 느슨하게 결합하고 싶을 때
- 예: View state 갱신과 navigation state 변화를 분리해서, 화면은 단순히 navigator 액션만 호출하고 실제 전환은 별도 계층에서 통제하고 싶을 수 있다.
강점
stack,tab,modal,deep link를 하나의NavigatorAPI로 통합한다.- 현재 활성 대상이 root stack인지, tab stack인지, modal stack인지
Navigator가 판단하므로 호출부가 단순해진다. backTo,backOrPush,replace,switchTab같은 imperative 동작을 같은 호출 방식으로 다룰 수 있다.enum기반 route와 명시적 dependency injection으로 타입 안전하고 추적 가능한 navigation 구성을 만든다.- SwiftUI는 화면 작성에 집중하고, 복잡한 전환 제어는 UIKit 엔진에 맡기는 역할 분리가 가능하다.
NavigationStack같은 최신 navigation API에만 묶이지 않아iOS 13까지 대응 가능한 구조를 가진다.
핵심 구성
Navigator
- push, replace, back, modal, tab 전환을 실행하는 메인 진입점
RouteRegistry
- route마다 어떤 화면을 만들지 등록하는 장소
RouteContext
- builder에 전달되는 실행 컨텍스트. route, navigator, dependencies를 포함
NavigationContainer/TabNavigationContainer
- SwiftUI에서 UIKit navigation 엔진을 올리는 bridge
DeepLinkParser
- URL을 typed route 기반 deep link로 바꾸는 parser 프로토콜
놓치기 쉬운 기능
- 다중 route push/present
- push([.home, .detail(id: "42")]), present([.login, .terms])처럼 한 번에 여러 화면을 구성할 수 있다.
- route 기반 되돌아가기
- backTo, backOrPush, currentRoutes로 현재 스택을 route 단위로 다룰 수 있다.
- 탭별 설정
- TabNavigationItem마다 prefersLargeTitles, hapticStyle을 다르게 줄 수 있다.
- 탭 UX 제어
- 같은 탭 재선택 시 root 복귀, isTabBarHidden, iOS 18 기본 탭 전환 애니메이션 비활성화를 지원한다.
- WrappingController 세부 제어
- title, isNavigationBarHidden, isTabBarHiddenWhenPushed로 네비게이션 바와 탭 바 표시를 화면 단위로 조절할 수 있다.
- modal 안정성
- sheet를 스와이프로 닫은 경우도 내부 modal 상태를 정리해 stale 참조가 남지 않도록 처리한다.
- preview helper
- Navigator.preview와 PreviewDependencies로 SwiftUI Preview에서 mock navigator를 빠르게 만들 수 있다.
<br/><br/>
현재 상태
- 구현됨: typed route 기반
Navigator,RouteRegistry, explicit DI, stack/modal/tab 연산, deep link entry point, SwiftUI bridge, preview helper, tab haptic, iOS 18 탭 전환 애니메이션 비활성화 옵션, demo app - 후순위: nested modal 일반화,
remove계열 연산, deep link parser 기본 구현, state restoration, UIKit-only 예제 확장
설치
Swift Package Manager
Xcode:
File > Add Package Dependencies...- 저장소 URL 입력
- 원하는 version / branch / commit 선택
- 앱 타깃에
TurboNavigator연결
로컬 패키지:
File > Add Package Dependencies...Add Local...선택TurboNavigator/Package.swift폴더 선택
Package.swift로 직접 추가하는 방법:
dependencies: [
.package(url: "https://github.com/indextrown/TurboNavigator.git", from: "1.1.1")
]targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "TurboNavigator", package: "TurboNavigator")
])
]공개 저장소를 기준으로 붙일 때는 https://github.com/indextrown/TurboNavigator.git와 from: "1.1.1" 조합을 사용하면 된다. 로컬에서 같이 개발 중이면 로컬 패키지 방식이 가장 빠르다.
빠른 시작
처음 붙일 때는 아래 순서대로 하면 된다.
1. Route 정의
enum AppRoute: Hashable {
case home
case detail(id: String)
case settings
}2. Dependencies 정의
struct AppDependencies {
let userRepository: UserRepository
let analytics: AnalyticsClient
}3. RouteRegistry 구성
let registry = RouteRegistry<AppDependencies, AppRoute>()
.registering(.home) { context in
// UIKit 프로젝트에서는 WrappingController 대신 UIViewController를 직접 반환하면 된다.
WrappingController(route: context.route, title: "Home") {
HomeView(navigator: context.navigator)
}
}
.registering(
extracting: { (route: AppRoute) -> String? in
guard case let .detail(id) = route else { return nil }
return id
},
build: { context, id in
WrappingController(route: context.route, title: "Detail") {
DetailView(
userID: id,
repository: context.dependencies.userRepository,
navigator: context.navigator)
}
})
.registering(.settings) { context in
WrappingController(route: context.route, title: "Settings") {
SettingsView(navigator: context.navigator)
}
}4. Navigator 생성
let navigator = Navigator(
dependencies: AppDependencies(
userRepository: DefaultUserRepository(),
analytics: DefaultAnalyticsClient()),
registry: registry
)5. SwiftUI에 연결
단일 스택 앱:
NavigationContainer(
navigator: navigator,
initialRoutes: [.home],
prefersLargeTitles: true
)탭 앱:
TabNavigationContainer(
navigator: navigator,
items: [
.init(
tag: 0,
route: .home,
tabBarItem: UITabBarItem(title: "Home", image: nil, tag: 0),
prefersLargeTitles: true,
hapticStyle: .selection),
.init(
tag: 1,
route: .settings,
tabBarItem: UITabBarItem(title: "Settings", image: nil, tag: 1),
prefersLargeTitles: false)
],
isTabBarHidden: false,
disablesSystemTabTransitionAnimation: true
)6. 화면에서 호출
navigator.push(.detail(id: "42"))
navigator.present(.settings)
navigator.presentFullScreen(.settings)
navigator.present(.settings, style: .pageSheet)
navigator.back()
navigator.backTo(.home)
navigator.backOrPush(.settings)
navigator.switchTab(tag: 1)
navigator.currentRoutes()필요하면 modal 스타일도 직접 지정할 수 있다.
navigator.present(.settings, style: .pageSheet)
navigator.present(.settings, style: .overFullScreen)7. Deep Link 연결
struct AppDeepLinkParser: DeepLinkParser {
func parse(url: URL) -> DeepLink<AppRoute>? {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return nil
}
switch components.host {
case "home":
return DeepLink(route: .home, action: .replace)
case "settings":
return DeepLink(route: .settings, action: .present(style: .fullScreen))
case "detail":
let id = components.queryItems?.first(where: { $0.name == "id" })?.value ?? ""
guard !id.isEmpty else { return nil }
return DeepLink(route: .detail(id: id), action: .push)
default:
return nil
}
}
}.onOpenURL { url in
navigator.handle(url: url, parser: AppDeepLinkParser())
}예시 URL:
turbonavigator://hometurbonavigator://detail?id=42turbonavigator://settings
사용 포인트
- route는
.home같은 고정 case와.detail(id:)같은 연관값 case를 함께 사용할 수 있다. - 외부 의존성은
Dependencies로 모으고 builder 안에서는context.dependencies로 접근한다. - 고정 route는
registering(_:), 연관값 route는registering(extracting:), 조건 기반 route는registering(matching:)로 등록한다. - builder는
WrappingController로 SwiftUI 화면을 감쌀 수도 있고, UIKit 프로젝트에서는UIViewController를 직접 반환해도 된다. WrappingController는title == nil이면 기본적으로 navigation bar를 숨긴다. 제목 없이 bar를 유지하고 싶으면title: ""또는isNavigationBarHidden: false를 명시해야 한다.TabNavigationContainer의disablesSystemTabTransitionAnimation을true로 주면 iOS 18 이상에서 기본 탭 전환 애니메이션을 끌 수 있다. 탭바 직접 탭 전환과navigator.switchTab(tag:)둘 다 같은 설정을 따른다.- 화면에서는
UIViewController를 직접 다루지 않고navigator만 호출하면 된다. backTo와backOrPush는 route를 추적할 수 있는 화면에서 동작한다.WrappingController를 쓰지 않는 UIKit 화면이라면AnyRouteIdentifiable를 직접 채택해야 한다.- deep link는 앱이 URL을 받고 parser가
DeepLink<Route>로 바꾼 뒤navigator.handle(url:parser:)로 연결한다.
Preview 지원
SwiftUI Preview에서 mock navigator를 빠르게 만들 수 있다.
#Preview {
SampleView(navigator: .preview)
}Dependencies가 있으면 PreviewDependencies를 채택해 기본 preview 값을 줄 수 있다.
extension AppDependencies: PreviewDependencies {
static var preview: Self {
.init(
userRepository: MockUserRepository(),
analytics: PreviewAnalyticsClient()
)
}
}
#Preview {
HomeView(navigator: .preview)
}도입 체크리스트
AppRoute를 만들었는가AppDependencies를 만들었는가RouteRegistry에 화면을 등록했는가Navigator를 생성했는가NavigationContainer또는TabNavigationContainer에 연결했는가- 화면에서
navigator.push/present/back를 호출하고 있는가
지원 연산
아래 시간복잡도는 현재 구현 기준의 대략적인 비용이다.
B: 등록된RouteBuilder개수S: 현재 활성UINavigationControllerstack 길이R: 한 번에 전달한 route 개수P: 앱이 구현한 parser 비용A: 파싱된 action 실행 비용
- Stack:
push,replace,back,backTo,backOrPush,currentRoutes - Modal:
present,presentFullScreen,dismissModal - Tab:
switchTab - State:
isModalActive - Deep Link:
handle(_:),handle(url:parser:)
Stack
push(_ route:):O(B + S)push(_ routes:):O(R * B + (S + R))replace(with:):O(R * B + R)back():O(1)backTo(_ route:):O(S)backOrPush(_ route:): route가 있으면O(S), 없으면O(S + B)currentRoutes():O(S)
Modal
present(_ route:):O(B)present(_ routes:):O(R * B + R)presentFullScreen(_ route:):O(B)presentFullScreen(_ routes:):O(R * B + R)dismissModal():O(1)isModalActive:O(1)
Tab
switchTab(tag:):O(1)switchTab(tag:popToRootIfSelected:): 탭 전환만 하면O(1), 같은 탭 재선택 후 root 복귀는O(S)
Deep Link
handle(_ deepLink:): deep link action에 따라push,replace,present비용을 그대로 따른다.handle(url:parser:):O(P + A)
정책:
- 각 탭은 독립
UINavigationController를 가진다. - modal은 한 번에 한 계층만 유지하고, 새 modal은 기존 modal을 교체한다.
- modal이 떠 있으면 modal 스택이 현재 활성 스택이 된다.
- deep link parsing은 앱이 담당하고, navigator는 파싱된 action을 실행한다.
DeepLink 직접 실행
URL parser 없이 DeepLink<Route>를 직접 만들어 실행할 수도 있다.
let deepLink = DeepLink(
routes: [.home, .detail(id: "42")],
action: .push
)
navigator.handle(deepLink)Architecture
flowchart TD
A[User Action / DeepLink URL] --> B{Input Type}
B -->|UI Action| C[Navigator.push / present / replace / switchTab]
B -->|DeepLink| D[DeepLinkParser]
D --> E[DeepLink]
E --> F[Navigator.handle]
C --> G[Navigator]
F --> G
G --> H{Action Type}
H -->|push / replace / back| I[SingleStackCoordinator]
H -->|present / dismiss| J[ModalCoordinator]
H -->|switchTab| K[TabCoordinator]
G --> L[RouteRegistry]
L --> M[RouteBuilder Match]
M --> N[RouteContext 생성]
N --> O[ViewController 생성]
O --> P{UI Type}
P -->|SwiftUI| Q[WrappingController]
P -->|UIKit| R[UIViewController]
I --> S[UINavigationController Stack 반영]
J --> T[Modal Navigation 반영]
K --> U[Tab Navigation 반영]
Q --> S
R --> S
Q --> T
R --> T
Q --> U
R --> U참고
- 패키지 정의: Package.swift
- 데모 앱 진입점: TurboNavigatorDemoApp.swift -->
Package Metadata
Repository: indextrown/turbonavigator
Default branch: main
README: README.md