Skip to content

Roy-wonji/TCAFlow

Repository files navigation

TCAFlow

Swift 6 호환 TCA용 Coordinator-style Navigation 라이브러리

TCAFlow는 TCACoordinators와 동일한 API를 제공하면서도 Hashable 제약 없이 사용할 수 있는 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("")
    }
}

📖 Navigation API 가이드

🔄 Push / Pop Navigation

// Push (화면 추가)
state.routes.push(.detail(.init()))
state.routes.push(.settings(.init()))

// Pop (뒤로 가기)
state.routes.goBack()           // 1단계 뒤로
state.routes.goBack(2)          // 2단계 뒤로
state.routes.goBackToRoot()     // 홈으로

// Stack에서만 Pop (presented 화면 유지)
state.routes.pop()              // push된 화면만 pop
state.routes.popToRoot()        // push된 화면 모두 pop

📑 Sheet / FullScreenCover

// Sheet 표시
state.routes.presentSheet(.settings(.init()))
state.routes.presentSheet(.profile(.init()), embedInNavigationView: true)

// FullScreenCover 표시  
state.routes.presentCover(.onboarding(.init()))

// Dismiss
state.routes.dismiss()          // 최상단 presented 화면 닫기
state.routes.dismiss(2)         // 2개 presented 화면 닫기
state.routes.dismissAll()       // 모든 presented 화면 닫기

🎯 특정 화면으로 이동

// 🔙 뒤로 이동 (goBackTo)
state.routes.goBackTo(\.home)         // 홈 화면까지 pop
state.routes.goBackTo(\.profile)      // 프로필 화면까지 pop

// 🎯 스마트 이동 (goTo) - 가장 일반적인 방식
state.routes.goTo(.settings(.init()))  // 설정으로 이동 (없으면 새로 생성)
state.routes.goTo(.profile(.init()))   // 프로필로 이동 (없으면 새로 생성)
state.routes.goTo(.detail(.init()))    // 상세로 이동 (없으면 새로 생성)

// 🏠 이전 화면으로 돌아가기 (특수 용도)
state.routes.goTo(\.home)             // 이전 홈으로 바로 돌아가기

// 🔍 조건부 이동
state.routes.goBackTo { route in
    route.screen.id == "specific-id"
}

state.routes.goTo { route in
    route.screen.isTargetScreen
}

💡 언제 어떤 방식을 사용할까?

// ✅ 일반적인 경우: 무조건 해당 화면으로 이동
case .settingsButtonTapped:
    state.routes.goTo(.settings(.init()))  // 없으면 새로 생성
    return .none

case .profileButtonTapped:
    state.routes.goTo(.profile(.init(userId: user.id)))
    return .none

// ✅ 특수한 경우: "이전 홈으로 돌아가기" 같은 경우
case .backToHomeButtonTapped:
    state.routes.goTo(\.home)  // 스택의 홈으로 바로 이동
    return .none

🏗️ 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을 경험해보세요! 🚀

About

TCA 아키텍쳐 플로우

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages