TCACoordinators

repository·main·Indexed 19 days ago

https://github.com/johnpatrickmorgan/tcacoordinators

A flexible navigation pattern for SwiftUI applications using The Composable Architecture (TCA). It enables developers to manage complex navigation flows—including push, sheet, and cover transitions—via a centralized coordinator state. Key features include support for deep-linking, automatic effect cancellation, automatic route synchronization with SwiftUI, and the ability to nest coordinators for distinct screen flows.

Tokens
4K
Snippets
4
Records
11
Agent score
68%

What's inside TCACoordinators

  1. Automatic effect cancellation and route updates

    main

    Automatic Route Updates

    The routes array is automatically synchronized with SwiftUI's navigation state. If a user performs an edge swipe to go back, uses a long-press gesture on the back button, or swipes to dismiss a sheet, the routes array in your state is updated automatically to reflect the change.

    Effect Cancellation

    By default, any in-flight effects (such as Tasks or async operations) initiated by a specific screen are automatically cancelled when that screen is popped or dismissed.

    To opt out of this automatic cancellation, pass cancellationId: nil to the .forEachRoute modifier in your reducer.

  2. Deep-linking with TCACoordinators

    main
    SwiftUI typically struggles with presenting multiple screens in a single state update (e.g., pushing three screens at once for a deep link). TCACoordinators solves this by intercepting large updates to the routes array and breaking them down into a series of smaller, sequential updates that SwiftUI can process, adding necessary delays between them automatically.
  3. Nesting Coordinators in TCACoordinators

    main

    You can break an app's screen flows into distinct, related flows by nesting coordinators. In this context, a 'Coordinator' is a SwiftUI view that contains its own TCARouter to manage a specific flow of screens.

    To nest a coordinator, you can append it to a parent coordinator's FlowStack. Because coordinators are standard SwiftUI views, they can be displayed using any standard SwiftUI presentation method (e.g., .sheet or .fullScreenCover).

  4. How screen identification works in the router

    main

    When defining the Coordinator.Action, you can identify screens in two ways:

    1. By Index: Use IndexedRouterActionOf<Screen> as the associated value for the router action. This is safe for standard navigation (push/pop) because indices remain stable. This is the default pattern used in the examples.
    2. By Identity: If you prefer using Identifiable screens, manage your routes as an IdentifiedArray. In this case, use IdentifiedRouterActionOf<Screen> for the router action case. This allows you to benefit from the same terse API while using stable identifiers.
  5. Migrate screen reducer state to Hashable (v0.12+)

    main

    Starting with version 0.12, to support Composable Architecture (>=0.19), the state of a screen reducer must conform to Hashable. This requirement enables more efficient scoping of the routes store into individual screen stores using key paths.

    Action Required: Ensure that the State type used in your screen reducers implements the Hashable protocol.

    // Example requirement:
    struct MyScreenState: Hashable {
        var someValue: String
    }
  6. Migrate from version 0.8 to 0.9+

    main

    Version 0.9 introduced breaking changes to align the library with The Composable Architecture (TCA), specifically regarding the use of case paths. You can choose between a Full migration to adopt the new API patterns or an Easy migration for a faster, short-term transition.

    Full Migration Steps

    1. Remove Protocol Conformances: Remove conformances to IndexedRouterState, IndexedRouterAction, IdentifiedRouterState, and IdentifiedRouterAction.
    2. Add @Reducer Macro: Add the @Reducer macro to your coordinator reducer to enable case path access on your action type.
    3. Simplify Actions: Replace routeAction and updateRoutes cases with a single case: case router(IndexedRouterActionOf<Screen>) or case router(IdentifiedRouterActionOf<Screen>), where Screen is your screen reducer. Update pattern matching to nest under the .router case (e.g., case .router(.routeAction(_, let action)):).
    4. Update forEachRoute: Pass the keypath and case path: forEachRoute(\.routes, action: \.router) { ... }.
    5. Scope TCARouter in Views: Instead of passing the entire store, scope it: TCARouter(store.scope(state: \.routes, action: \.router)) { ... }.
    6. Update routeWithDelaysIfUnsupported: Pass the action case path: Effect.routeWithDelaysIfUnsupported(state.routes, action: \.router) { ... }.
  7. Implement the Coordinator pattern in TCA

    main

    To implement navigation using TCACoordinators, follow these three steps:

    1. Create a Screen Reducer: Define an enum reducer using the @Reducer macro that encapsulates all possible screens in a navigation flow. Each case represents a different screen's reducer.
    2. Create a Coordinator Reducer: Define a coordinator that manages an array of Route<Screen.State>. The coordinator's action must include a case for the router (e.g., case router(IndexedRouterActionOf<Screen>)). Use .forEachRoute in the reducer body to apply the screen reducer to the routes array.
    3. Create a Coordinator View: Use TCARouter in your SwiftUI view. It takes a scoped store and a closure that maps each screen case to its corresponding view.

    This approach decouples individual screens from the navigation logic, allowing screens to be reused in different contexts without knowing their position in the stack.

    // 1. Screen Reducer
    @Reducer(state: .hashable)
    enum Screen {
      case home(Home)
      case numbersList(NumbersList)
      case numberDetail(NumberDetail)
    }
    
    // 2. Coordinator Reducer
    @Reducer
    struct Coordinator {
      @ObservableState
      struct State: Equatable {
        var routes: [Route<Screen.State>]
      } 
    
      enum Action {
        case router(IndexedRouterActionOf<Screen>)
      }
    
      var body: some ReducerOf<Self> {
        Reduce { state, action in
          switch action {
          case .router(.routeAction(_, .home(.startTapped))):
            state.routes.presentSheet(.numbersList(.init(numbers: [0,1,2,3])), embedInNavigationView: true)
            return .none
          // ... other cases
          default: return .none
          }
        }
        .forEachRoute(\.routes, action: \.router)
      }
    }
    
    // 3. Coordinator View
    struct CoordinatorView: View {
      let store: StoreOf<Coordinator>
    
      var body: some View {
        TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
          switch screen.case {
          case let .home(store): HomeView(store: store)
          case let .numbersList(store): NumbersListView(store: store)
          case let .numberDetail(store): NumberDetailView(store: store)
          }
        }
      }
    }
  8. Best practices and limitations for nesting coordinators

    main

    When nesting coordinators, follow these two architectural constraints to avoid navigation conflicts:

    1. Avoid branching navigation paths: A child coordinator should ideally be the last element in the parent's routes array. Once the child coordinator is active, it takes over the responsibility for showing new screens. If the parent attempts to present screens while the child is also presenting, a navigation conflict may occur.

    2. Present child coordinators instead of pushing them: Due to how NavigationStack manages state, you cannot push a child coordinator (which has its own routes) onto a parent coordinator's navigation stack. Instead, child coordinators should be presented using SwiftUI presentation styles like .sheet or .fullScreenCover.

  9. Perform an easy migration from version 0.8

    main

    If you want to migrate quickly without adopting the full new API immediately, you can skip the protocol removals and action simplification. Instead, manually add a case path to your coordinator's action type using CasePathable to bridge the old structure to the new requirements.

    // Quick update for an action that formerly conformed to `IdentifiedRouterAction`.
    enum Action: CasePathable {
      case updateRoutes(IdentifiedArrayOf<Route<Screen.State>>)
      case routeAction(Screen.State.ID, action: Screen.Action)
    
      static var allCasePaths = AllCasePaths()
    
      struct AllCasePaths {
        var router: AnyCasePath<Action, IdentifiedRouterAction<Screen.State, Screen.Action>> {
          AnyCasePath { routerAction in
            switch routerAction {
            case let .routeAction(id, action):
              return .routeAction(id, action: action)
            case let .updateRoutes(newRoutes):
              return .updateRoutes(IdentifiedArray(uniqueElements: newRoutes))
            }
          } extract: { action in
            switch action {
            case let .routeAction(id, action: action):
              return .routeAction(id: id, action: action)
            case let .updateRoutes(newRoutes):
              return .updateRoutes(newRoutes.elements)
            }
          }
        }
      }
    }
  10. Full migration diff for IndexedCoordinator

    main

    This diff demonstrates the complete transformation of a coordinator and its view from the 0.8 pattern to the 0.9+ pattern, including state/action changes, reducer logic updates, and view scoping.

    struct IndexedCoordinatorView: View {
      let store: StoreOf<IndexedCoordinator>
    
      var body: some View {
    -    TCARouter(store) { screen in
    +    TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
          SwitchStore(screen) { screen in
            switch screen {
            case .home:
              CaseLet(
                \Screen.State.home,
                action: Screen.Action.home,
                then: HomeView.init
              )
            case .numbersList:
              CaseLet(
                \Screen.State.numbersList,
                action: Screen.Action.numbersList,
                then: NumbersListView.init
              )
            case .numberDetail:
              CaseLet(
                \Screen.State.numberDetail,
                action: Screen.Action.numberDetail,
                then: NumberDetailView.init
              )
            }
          }
        }
      }
    }
    
    +@Reducer
    struct IndexedCoordinator {
    -  struct State: Equatable, IndexedRouterState {
    +  struct State: Equatable {
        var routes: [Route<Screen.State>]
      }
    
    -  enum Action: IndexedRouterAction {
    +  enum Action {
    -    case routeAction(Int, action: Screen.Action)
    -    case updateRoutes([Route<Screen.State>])
    +    case router(IndexedRouterActionOf<Screen>)
      }
    
      var body: some ReducerOf<Self> {
        Reduce<State, Action> {
          switch action {
    -      case .routeAction(_, .home(.startTapped)):
    +      case .router(.routeAction(_, .home(.startTapped))):
            state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true)
    
    -      case let .routeAction(_, .numbersList(.numberSelected(number))):
    +      case let .router(.routeAction(_, .numbersList(.numberSelected(number)))):
            state.routes.push(.numberDetail(.init(number: number)))
    
    -      case .routeAction(_, .numberDetail(.goBackTapped)):
    +      case .router(.routeAction(_, .numberDetail(.goBackTapped))):
            state.routes.goBack()
    
    -      case .routeAction(_, .numberDetail(.goBackToRootTapped)):
    -        return .routeWithDelaysIfUnsupported(state.routes) {
    +      case .router(.routeAction(_, .numberDetail(.goBackToRootTapped))):
    +        return .routeWithDelaysIfUnsupported(state.routes, action: \.router) {
              $0.goBackToRoot()
            }
    
          default:
            break
          }
          return .none
        }
    -    .forEachRoute {
    +    .forEachRoute(\.routes, action: \.router) {
          Screen()
        }
      }
    }
  11. Manage navigation with Route convenience methods

    main

    The routes array in your Coordinator state provides several convenience methods to handle common navigation transitions. When using presentSheet or presentCover, pass embedInNavigationView: true if you want the presented screen to be able to push new screens onto a navigation stack.

    MethodEffect
    pushPushes a new screen onto the stack.
    presentSheetPresents a new screen as a sheet.
    presentCoverPresents a new screen as a full-screen cover.
    goBackGoes back one screen in the stack.
    goBackToRootGoes back to the very first screen in the stack.
    goBackToGoes back to a specific screen in the stack.
    popPops the current screen if it was pushed.
    dismissDismisses the most recently presented screen.