Contents

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

  1. Import 변경: TCACoordinatorsTCAFlow
  2. Router 변경: TCARouterTCAFlowRouter
  3. Hashable 제거: Screen State에서 Hashable 삭제
  4. 매크로 적용: @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

🤝 기여

기여는 언제나 환영입니다!

  1. Fork the repository
  2. Create your feature branch
  3. Make your changes
  4. Add tests if applicable
  5. Submit a pull request

📄 License

MIT License - 자세한 내용은 LICENSE 파일을 확인하세요.


TCAFlow로 더 깔끔하고 유연한 TCA Navigation을 경험해보세요! 🚀

Package Metadata

Repository: roy-wonji/tcaflow

Default branch: main

README: README.md