roy-wonji/tcaflow
**Swift 6 호환 TCA용 Coordinator-style Navigation 라이브러리**
✨ 주요 특징
- 🚀 Hashable 제약 없음 -
Equatable만으로 충분 - 📱 Native NavigationStack - iOS 16+의 최신 Navigation API 활용
- 🎯 TCA 전용 설계 - 불필요한 의존성 없음
- 🏗️ Nested Coordinator - 복잡한 플로우 완벽 지원
- 🔄 Migration 친화적 - TCACoordinators에서 쉬운 전환
- ⚡ Swift 6 호환 - 최신 Swift 기능 활용
- 🎨 @FlowCoordinator 매크로 - 보일러플레이트 코드 자동 생성
🆚 TCACoordinators와 비교
| 특징 | TCACoordinators | TCAFlow | |------|-----------------|---------| | Screen State 제약 | Hashable 필수 | Equatable만 요구 ✅ | | 의존성 | TCA + FlowStacks | TCA만 ✅ | | Navigation API | FlowStacks 래핑 | Native NavigationStack ✅ | | 성능 | 간접 참조 오버헤드 | 직접 참조 최적화 ✅ | | Nested 지원 | 제한적 | 완전 지원 ✅ |
📋 요구사항
- Swift: 6.0+
- TCA: 1.25.5+
- 플랫폼: iOS 16.0+ / macOS 13.0+ / watchOS 9.0+ / tvOS 16.0+
- Xcode: 16.0+ (매크로 지원)
📦 설치
Swift Package Manager
dependencies: [
.package(url: "https://github.com/Roy-wonji/TCAFlow.git", from: "1.0.2")
].target(
name: "App",
dependencies: ["TCAFlow"] // 매크로 자동 포함 ✅
)참고: TCAFlow 패키지에는 @FlowCoordinator 매크로가 자동으로 포함됩니다.
🎨 @FlowCoordinator 매크로
TCAFlow는 @FlowCoordinator 매크로를 제공하여 Coordinator의 보일러플레이트 코드를 자동으로 생성합니다.
✨ 매크로를 사용하면 이렇게 간단해집니다!
기존 방식 (수동 작성)
@Reducer
struct AppCoordinator {
@ObservableState
struct State: Equatable {
var routes: [Route<Screen.State>]
init() {
routes = [.root(.home(.init()), embedInNavigationView: true)]
}
}
@CasePathable
enum Action {
case router(IndexedRouterActionOf<Screen>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
return handleRoute(state: &state, action: action)
}
.forEachRoute(\.routes, action: \.router)
}
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
// 라우팅 로직...
}
}💫 매크로 사용 (자동 생성)
@FlowCoordinator(screen: "Screen", navigation: true)
struct AppCoordinator {
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
// 라우팅 로직만 작성!
switch action {
case .router(.routeAction(_, .home(.detailTapped))):
state.routes.push(.detail(.init()))
return .none
default:
return .none
}
}
}
extension AppCoordinator {
@Reducer
enum Screen {
case home(HomeFeature)
case detail(DetailFeature)
}
}
extension AppCoordinator.Screen.State: Equatable {}🔧 매크로 사용법
방식 1: struct에 직접 적용 (권장)
@FlowCoordinator(screen: "Screen", navigation: true)
struct AppCoordinator {
// ✅ 자동 생성: State, Action, body
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
// 라우팅 로직만 작성
}
}
extension AppCoordinator {
@Reducer
enum Screen {
case home(HomeFeature)
case detail(DetailFeature)
}
}
// ✅ 자동 생성: Screen.State: Equatable방식 2: extension에 적용
struct AppCoordinator {}
@FlowCoordinator(navigation: true)
extension AppCoordinator {
@Reducer
enum Screen {
case home(HomeFeature)
case detail(DetailFeature)
}
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
// 라우팅 로직
}
}📋 매크로 파라미터
@FlowCoordinator(
screen: "Screen", // Screen enum 이름 (optional)
navigation: true // root route에 embedInNavigationView 적용 (기본값: true)
)screen: Screen enum의 이름을 명시적으로 지정navigation:true이면 root route가 NavigationView를 embed
🎛️ 커스터마이징
Action에 추가 케이스가 필요한 경우
@FlowCoordinator(screen: "Screen")
struct NestedCoordinator {
@CasePathable
enum Action {
case router(IndexedRouterActionOf<Screen>)
case backToMain // ✅ 추가 액션
case deepLink(URL)
}
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
switch action {
case .backToMain:
// 커스텀 로직
return .none
case .router(let routerAction):
// 라우팅 로직
return .none
default:
return .none
}
}
}State 초기화를 커스터마이징하는 경우
@FlowCoordinator(screen: "Screen")
struct AppCoordinator {
@ObservableState
struct State: Equatable {
var routes: [Route<Screen.State>]
var isLoggedIn: Bool // ✅ 추가 프로퍼티
init(isLoggedIn: Bool = false) {
self.isLoggedIn = isLoggedIn
self.routes = isLoggedIn
? [.root(.home(.init()), embedInNavigationView: true)]
: [.root(.login(.init()), embedInNavigationView: true)]
}
}
// ✅ Action, body는 자동 생성
}🚀 빠른 시작
1️⃣ 기본 Feature 정의
import ComposableArchitecture
import TCAFlow
@Reducer
struct HomeFeature {
@ObservableState
struct State: Equatable { // ✅ Hashable 불필요!
var title = "홈 화면"
}
@CasePathable
enum Action {
case detailButtonTapped
case settingsButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .detailButtonTapped, .settingsButtonTapped:
return .none // Navigation은 Coordinator에서 처리
}
}
}
}2️⃣ Coordinator 구현 (매크로 사용)
@FlowCoordinator(screen: "Screen", navigation: true)
struct AppCoordinator {
// ✨ State, Action, body는 매크로가 자동 생성!
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
switch action {
// 📱 Navigation 로직만 집중!
case .router(.routeAction(_, .home(.detailButtonTapped))):
state.routes.push(.detail(.init(title: "상세 화면")))
return .none
case .router(.routeAction(_, .home(.settingsButtonTapped))):
state.routes.presentSheet(.settings(.init()))
return .none
case .router(.routeAction(_, .detail(.backTapped))):
state.routes.goBack()
return .none
default:
return .none
}
}
}
// 📄 Screen 정의
extension AppCoordinator {
@Reducer
enum Screen {
case home(HomeFeature)
case detail(DetailFeature)
case settings(SettingsFeature)
}
}
// ✨ Screen.State: Equatable도 매크로가 자동 생성!3️⃣ View 연결
struct AppCoordinatorView: View {
@Bindable var store: StoreOf<AppCoordinator>
var body: some View {
TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in
switch screen.case {
case .home(let store):
HomeView(store: store)
case .detail(let store):
DetailView(store: store)
case .settings(let store):
SettingsView(store: store)
}
}
}
}
struct HomeView: View {
@Bindable var store: StoreOf<HomeFeature>
var body: some View {
VStack(spacing: 20) {
Text(store.title)
.font(.largeTitle)
Button("상세 화면으로") {
store.send(.detailButtonTapped)
}
Button("설정") {
store.send(.settingsButtonTapped)
}
}
.navigationTitle("홈")
}
}🏗️ Nested Coordinator
복잡한 플로우는 Nested Coordinator로 분리할 수 있습니다.
v1.0.2: 중첩 코디네이터가 부모
NavigationStack을 직접 활용합니다.navigationDestination(isPresented:)체이닝으로 NavigationStack 1개만 사용하여 네이티브 슬라이드 애니메이션과 스와이프백이 자동 지원됩니다.
AppCoordinator (NavigationStack)
├─ HomeView (root)
├─ [push] → ProfileView ← _InlineRouteChain(index: 0)
│ └─ navigationDestination(isPresented:)
│ └─ [push] → SettingView ← _InlineRouteChain(index: 1)// 🎯 프로필 전용 Coordinator
@Reducer
struct ProfileCoordinator {
@ObservableState
struct State: Equatable {
var routes: [Route<ProfileScreen.State>] = [
.root(.profile(.init()), embedInNavigationView: true)
]
}
@CasePathable
enum Action {
case router(IndexedRouterActionOf<ProfileScreen>)
case navigation(NavigationAction)
}
enum NavigationAction: Equatable {
case presentRoot // 부모로 돌아가기
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .router(.routeAction(_, .profile(.settingTapped))):
state.routes.push(.setting(.init()))
return .none
case .router(.routeAction(_, .setting(.backTapped))):
state.routes.goBack()
return .none
default:
return .none
}
}
.forEachRoute(\.routes, action: \.router)
}
}
// 📱 메인 앱에서 사용
extension AppCoordinator {
@Reducer
enum Screen {
case home(HomeFeature)
case profile(ProfileCoordinator) // 🎯 Nested Coordinator
}
}🎨 @FlowCoordinator vs 수동 작성
| 특징 | 수동 작성 | @FlowCoordinator 매크로 | |------|----------|----------------------| | 코드 길이 | ~30줄 | ~10줄 ✅ | | 보일러플레이트 | 많음 | 자동 생성 ✅ | | 실수 가능성 | 높음 | 낮음 ✅ | | 커스터마이징 | 완전 자유 | 일부 제약 | | 학습 곡선 | 높음 | 낮음 ✅ |
🤔 언제 무엇을 사용할까?
✅ @FlowCoordinator 매크로 사용 권장
- 새 프로젝트 시작
- 간단한 Coordinator
- 빠른 프로토타이핑
- 보일러플레이트 줄이고 싶을 때
✅ 수동 작성 권장
- 기존 코드가 많을 때
- 매우 복잡한 State 초기화
- Action에 많은 커스텀 케이스 필요
- 매크로를 학습할 시간이 없을 때
💡 실전 팁
🔧 매크로 사용 시 팁
@FlowCoordinator(screen: "Screen", navigation: true)
struct AppCoordinator {
// ✅ handleRoute 메서드는 필수!
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
switch action {
case .router(let routerAction):
return handleRouterAction(state: &state, action: routerAction)
default:
return .none
}
}
// 🎯 라우터 액션을 별도 메서드로 분리하면 깔끔
private func handleRouterAction(
state: inout State,
action: IndexedRouterActionOf<Screen>
) -> Effect<Action> {
switch action {
case .routeAction(_, .home(.detailTapped)):
state.routes.push(.detail(.init()))
return .none
// ...
}
}
}🔧 라우터 액션 헬퍼 (수동 작성 시)
extension AppCoordinator {
// 📝 읽기 쉬운 헬퍼 함수
private func handleNavigation(
state: inout State,
action: IndexedRouterActionOf<Screen>
) -> Effect<Action> {
switch action {
case .routeAction(_, .home(let homeAction)):
return handleHomeAction(state: &state, action: homeAction)
case .routeAction(_, .detail(let detailAction)):
return handleDetailAction(state: &state, action: detailAction)
default:
return .none
}
}
private func handleHomeAction(
state: inout State,
action: HomeFeature.Action
) -> Effect<Action> {
switch action {
case .detailButtonTapped:
state.routes.push(.detail(.init(title: "상세")))
return .none
}
}
}🎨 Route 확장
extension Array where Element == Route<AppCoordinator.Screen.State> {
var isOnDetailScreen: Bool {
last?.screen.case.is(\.detail) == true
}
mutating func pushDetailWithId(_ id: String) {
push(.detail(.init(id: id)))
}
}🆕 1.1.0 신규 API
1️⃣ Sheet Detent 지원
하프시트, detent, drag indicator를 SheetConfiguration으로 설정합니다.
// 프리셋 사용
state.routes.presentSheet(.settings(.init()), configuration: .half)
state.routes.presentSheet(.profile(.init()), configuration: .halfAndFull)
// 커스텀 설정
state.routes.presentSheet(.filter(.init()), configuration: SheetConfiguration(
detents: [.medium, .large],
showDragIndicator: true
))| 프리셋 | 설명 | |--------|------| | .default | 풀 시트 ([.large]) | | .half | 하프 시트 ([.medium]) | | .halfAndFull | 하프 + 풀 ([.medium, .large]) |
2️⃣ Route Logger
디버그 모드에서 route 변경을 자동 로깅하는 미들웨어입니다.
var body: some Reducer<State, Action> {
Reduce { state, action in
handleRoute(state: &state, action: action)
}
.forEachRoute(\.routes, action: \.router)
.routeLogging(level: .verbose, prefix: "🏠 [App]")
}| 레벨 | 설명 | |------|------| | .minimal | route 변경 요약만 출력 | | .verbose | 상세한 route 상태 출력 |
3️⃣ Route Guard
네비게이션을 인터셉트하여 조건부로 허용/거부합니다.
// Guard 정의
struct AuthGuard: RouteGuard {
func canNavigate<Screen>(
from currentRoutes: [Route<Screen>],
to newRoutes: [Route<Screen>]
) -> RouteGuardResult {
if isAuthenticated {
return .allow
} else {
return .reject(reason: "로그인이 필요합니다")
}
}
}
// Reducer에 적용
var body: some Reducer<State, Action> {
Reduce { state, action in
handleRoute(state: &state, action: action)
}
.forEachRoute(\.routes, action: \.router)
.routeGuard(AuthGuard())
}
// 수동 체크
let canProceed = checkRouteGuard(AuthGuard(), from: state.routes, to: newRoutes)4️⃣ DeepLink Helper
URL을 Route로 변환하는 프로토콜 기반 딥링크 처리입니다.
// Handler 정의
struct AppDeepLinkHandler: DeepLinkHandler {
typealias Screen = AppCoordinator.AppScreen.State
func routes(for url: URL) -> [Route<Screen>]? {
guard let host = url.host else { return nil }
let params = url.deepLinkParameters
switch host {
case "detail":
return [
.root(.home(.init()), embedInNavigationView: true),
.push(.detail(.init(title: params["title"] ?? "Detail")))
]
default:
return nil
}
}
}
// 사용
state.routes.handleDeepLink(
URL(string: "app://detail?title=Hello")!,
handler: AppDeepLinkHandler(),
mode: .replace // .replace | .keepRoot | .append
)| 모드 | 설명 | |------|------| | .replace | 전체 route를 교체 | | .keepRoot | root를 유지하고 나머지 교체 | | .append | 기존 route에 추가 |
URL 헬퍼:
let url = URL(string: "app://detail?title=Hello&id=123")!
url.deepLinkParameters // ["title": "Hello", "id": "123"]
url.deepLinkPathComponents // ["detail"]5️⃣ Tab Coordinator
탭 기반 네비게이션을 위한 전용 라우터입니다.
// TabItem 정의
let tabs = [
TabItem(title: "홈", icon: "house.fill", tag: 0),
TabItem(title: "프로필", icon: "person.fill", tag: 1),
TabItem(title: "설정", icon: "gear", tag: 2),
]
// View
TCAFlowTabRouter(
selectedTab: $store.selectedTab,
tabs: tabs,
onReselect: { tab in store.send(.tabReselected(tab)) }
) { index in
switch index {
case 0: HomeCoordinatorView(store: homeStore)
case 1: ProfileCoordinatorView(store: profileStore)
case 2: SettingsCoordinatorView(store: settingsStore)
default: EmptyView()
}
}TabCoordinatorState 프로토콜:
struct AppState: TabCoordinatorState {
var selectedTab: Int = 0
mutating func popToRoot(tab: Int) { /* 탭별 root로 이동 */ }
}6️⃣ 전환 애니메이션 커스텀
route 전환 시 애니메이션을 지정합니다.
// View에서 사용
DetailView(store: store)
.routeTransition(.fade(duration: 0.3))
SettingsView(store: store)
.routeTransition(.spring(duration: 0.35, bounce: 0.2))| 애니메이션 | 설명 | |-----------|------| | .default | 시스템 기본 | | .fade(duration:) | 페이드 인/아웃 | | .spring(duration:bounce:) | 스프링 | | .easeInOut(duration:) | ease-in-out | | .none | 애니메이션 없음 |
7️⃣ Route 상태 저장/복원
Codable Screen과 함께 route 상태를 UserDefaults에 저장/복원합니다.
// 저장
state.routes.saveRoutes(to: "app_routes")
// 복원
if let saved: [Route<Screen.State>] = .loadRoutes(from: "app_routes") {
state.routes = saved
}
// 직접 사용
RoutePersistence.save(state.routes, key: "app_routes")
let routes: [Route<Screen.State>]? = RoutePersistence.load(key: "app_routes")
RoutePersistence.clear(key: "app_routes")⚠️ Screen.State가
Codable을 준수해야 합니다.
🔄 Migration from TCACoordinators
1️⃣ 기본 마이그레이션
// Before (TCACoordinators)
import TCACoordinators
TCARouter(store) { screen in ... }
// After (TCAFlow)
import TCAFlow
TCAFlowRouter(store) { screen in ... }
// ✅ State에서 Hashable 제거
struct MyState: Hashable, Equatable { ... } // ❌
struct MyState: Equatable { ... } // ✅2️⃣ 매크로로 더 간단하게!
Before (TCACoordinators - 수동 작성)
@Reducer
struct AppCoordinator {
@ObservableState
struct State: Hashable, Equatable { // Hashable 필요
var routes: [Route<Screen.State>] = [...]
}
@CasePathable
enum Action {
case router(IndexedRouterActionOf<Screen>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// 라우팅 로직...
}
.forEachRoute(\.routes, action: \.router)
}
}After (TCAFlow - 매크로 사용)
@FlowCoordinator(screen: "Screen", navigation: true) // 🎨 매크로로 한 줄!
struct AppCoordinator {
func handleRoute(state: inout State, action: Action) -> Effect<Action> {
// 라우팅 로직만 작성하면 끝!
switch action {
case .router(.routeAction(_, .home(.detailTapped))):
state.routes.push(.detail(.init()))
return .none
default:
return .none
}
}
}🚀 Migration Steps
- Import 변경:
TCACoordinators→TCAFlow - Router 변경:
TCARouter→TCAFlowRouter - Hashable 제거: Screen State에서
Hashable삭제 - 매크로 적용:
@FlowCoordinator매크로로 보일러플레이트 제거 (선택사항)
📚 예제 프로젝트
완전한 예제는 Example/ 폴더에서 확인하세요:
Example/TCAFlowExamples/
├── TCAFlowExamplesApp.swift
├── Coordinators/
│ ├── DemoCoordinator.swift # @FlowCoordinator 매크로 사용 예제 🎨
│ └── DemoCoordinatorView.swift # 라우터 뷰
└── Features/
├── Home/ # 홈 화면 + goTo 예제
├── Flow/ # 플로우 예제 + goTo 예제
├── Detail/ # 상세 화면 + goTo 예제
├── Settings/ # 설정 화면 + goTo 예제
└── Nested/ # 중첩 코디네이터 예제🎨 매크로 사용 예제: DemoCoordinator.swift에서 @FlowCoordinator 매크로가 어떻게 보일러플레이트를 줄이는지 확인할 수 있습니다!
🔨 예제 빌드
cd Example/TCAFlowExamples
open TCAFlowExamples.xcodeproj또는
xcodebuild \
-project Example/TCAFlowExamples/TCAFlowExamples.xcodeproj \
-scheme TCAFlowExamples \
-destination 'generic/platform=iOS Simulator' \
build🤝 기여
기여는 언제나 환영입니다!
- Fork the repository
- Create your feature branch
- Make your changes
- Add tests if applicable
- Submit a pull request
📄 License
MIT License - 자세한 내용은 LICENSE 파일을 확인하세요.
TCAFlow로 더 깔끔하고 유연한 TCA Navigation을 경험해보세요! 🚀
Package Metadata
Repository: roy-wonji/tcaflow
Default branch: main
README: README.md