Skip to content

Powerful navigation in the Composable Architecture via the coordinator pattern

License

Notifications You must be signed in to change notification settings

johnpatrickmorgan/TCACoordinators

Repository files navigation

TCACoordinators

The coordinator pattern in the Composable Architecture

TCACoordinatorsbrings a flexible approach to navigation in SwiftUI using theComposable Architecture (TCA).It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA witha novel approach to handling navigation in SwiftUI.

You might like this library if you want to:

✅ Support deeplinks intodeeplynested navigation routes in your app.
✅ Easily reuse screen features within different navigation contexts.
✅ Easily go back to the root screen or a specific screen in the navigation stack.
✅ Keep all navigation logic in a single place.
✅ Break an app's navigation into multiple reusable coordinators and compose them together.
✅ Use a single system to unify push navigation and modal presentation.

The library works by translating the array of screens into a hierarchy of nestedNavigationLinks and presentation calls, so:

🚫 It does not rely on UIKit at all.
🚫 It does not useAnyViewto type-erase screens.
🚫 It does not try to recreateNavigationViewfrom scratch.

Usage example

Step 1 - Create a screen reducer

First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Thanks to the@Reducer macro,this can be easily achieved with an enum reducer, e.g. the following (whereHome,NumbersListandNumberDetailare the individual screen reducers):

@Reducer(state:.equatable)
enumScreen{
casehome(Home)
casenumbersList(NumbersList)
casenumberDetail(NumberDetail)
}

Step 2 - Create a coordinator reducer

The coordinator will manage multiple screens in a navigation flow. Its state should include an array ofRoute<Screen.State>s, representing the navigation stack: i.e. appending a new screen state to this array will trigger the corresponding screen to be pushed or presented.Routeis an enum whose cases capture the screen state and how it should be shown, e.g.case push(Screen.State).

@Reducer
structCoordinator{
@ObservableState
structState:Equatable{
varroutes:[Route<Screen.State>]
}
...
}

The coordinator's action should include a special case, which will allow screen actions to be dispatched to the correct screen in the routes array, and allow the routes array to be updated automatically, e.g. when a user taps 'Back':

@Reducer
structCoordinator{
...

enumAction{
caserouter(IndexedRouterActionOf<Screen>)
}
...
}

The coordinator reducer defines any logic for presenting and dismissing screens, and usesforEachRouteto further apply theScreenreducer to each screen in theroutesarray.forEachRoutetakes two arguments: a keypath for the routes array and a case path for the router action case:

@Reducer
structCoordinator{
...
varbody:someReducerOf<Self>{
Reduce<State,Action>{state,actionin
switch action{
case.router(.routeAction(_,.home(.startTapped))):
state.routes.presentSheet(.numbersList(.init(numbers:Array(0..<4))),embedInNavigationView:true)

case.router(.routeAction(_,.numbersList(.numberSelected(letnumber)))):
state.routes.push(.numberDetail(.init(number:number)))

case.router(.routeAction(_,.numberDetail(.showDouble(letnumber)))):
state.routes.presentSheet(.numberDetail(.init(number:number*2)))

case.router(.routeAction(_,.numberDetail(.goBackTapped))):
state.routes.goBack()

default:
break
}
return.none
}
.forEachRoute(\.routes,action:\.router)
}
}

Step 3 - Create a coordinator view

With that in place, aCoordinatorViewcan be created. It will use aTCARouter,which translates the array of routes into a nested list of screen views with invisibleNavigationLinksand presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, theTCARoutertakes a closure that can create the view for any screen in the navigation flow. A switch statement is the natural way to achieve that, with a case for each of the possible screens:

structCoordinatorView:View{
letstore:StoreOf<Coordinator>

varbody:someView{
TCARouter(store.scope(state:\.routes,action:\.router)){screenin
switch screen.case{
caselet.home(store):
HomeView(store:store)

caselet.numbersList(store):
NumbersListView(store:store)

caselet.numberDetail(store):
NumberDetailView(store:store)
}
}
}
}

Convenience methods

The routes array can be managed using normal Array methods such asappend,but a number of convenience methods are available for common transformations, such as:

Method Effect
push Pushes a new screen onto the stack.
presentSheet Presents a new screen as a sheet.†
presentCover Presents a new screen as a full-screen cover.†
goBack Goes back one screen in the stack.
goBackToRoot Goes back to the very first screen in the stack.
goBackTo Goes back to a specific screen in the stack.
pop Pops the current screen if it was pushed.
dismiss Dismisses the most recently presented screen.

PassembedInNavigationView: trueif you want to be able to push screens from the presented screen.

Routes array automatically updated

If the user taps the back button, the routes array will be automatically updated to reflect the new navigation state. Navigating back with an edge swipe gesture or via a long-press gesture on the back button will also update the routes array automatically, as will swiping to dismiss a sheet.

Cancellation of in-flight effects on dismiss

By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. To opt out of automatic cancellation, passcancellationId: niltoforEachRoute.

Making complex navigation updates

SwiftUI does not allow more than one screen to be pushed, presented or dismissed within a single update. This makes it tricky to make large updates to the navigation state, e.g. when deeplinking straight to a view several layers deep in the navigation hierarchy, when going back multiple presentation layers to the root, or when restoring arbitrary navigation state. This library provides a workaround: it can break down large unsupported updates into a series of smaller updates that SwiftUI does support, interspersed with the necessary delays, and make that available as an Effect to be returned from a coordinator reducer. You just need to wrap route mutations in a call toEffect.routeWithDelaysIfUnsupported,e.g.:

returnEffect.routeWithDelaysIfUnsupported(state.routes,action:\.router){
$0.goBackToRoot()
}
returnEffect.routeWithDelaysIfUnsupported(state.routes,action:\.router){
$0.push(...)
$0.push(...)
$0.presentSheet(...)
}

Composing child coordinators

The coordinator is just like any other UI unit in the Composable Architecture - comprising aViewand aReducerwithStateandActiontypes. This means they can be composed in all the normal ways SwiftUI and TCA allow. You can present a coordinator, add it to aTabView,even push or present a child coordinator from a parent coordinator by adding it to the routes array. When doing so, it is best that the child coordinator is only ever the last element of the parent's routes array, as it will take over responsibility for pushing and presenting new screens until dismissed. Otherwise, the parent might attempt to push screen(s) when the child is already pushing screen(s), causing a conflict.

Identifying screens

In the example given, theCoordinator.Action's router case included an associated value ofIndexedRouterActionOf<Screen>.That means that screens were identified by their index in the routes array. This is safe because the index is stable for standard navigation updates - e.g. pushing and popping do not affect the indexes of existing screens. However, if you prefer to useIdentifiablescreens, you can manage the screens as anIdentifiedArrayinstead. TheCoordinator.Action's router case will then have an associated value ofIdentifiedRouterActionOf<Screen>instead, and benefit from the same terse API as the example above.

Flexible and reusable

If the flow of screens needs to change, the change can be made easily in one place. The screen views and reducers (along with their state and action types) no longer need to have any knowledge of any other screens in the navigation flow - they can simply send an action and leave the coordinator to decide whether a new view should be pushed or presented - which makes it easy to re-use them in different contexts, and helps separate screen responsibilities from navigation responsibilities.

How does it work?

This library usesFlowStacksfor hoisting navigation state out of individual screens. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture.

Migrating from v0.8 and lower

There has been an API change from v0.8 to v0.9, to bring the library's APIs more in-line with the Composable Architecture, including the use of case paths. If you're migrating to these new APIs please see themigration docs.

About

Powerful navigation in the Composable Architecture via the coordinator pattern

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages