
In this page, we will be looking at how we have architect the navigation using TCA.
Pre-requisite:
This page is a continuation of the above article
As discussed in the TCA Architecture, the Main Store which is associated with the Root View contains children’s Stores.
Figure 1
Let’s take an example,
struct View0State: Equatable { var view1State: View1State? = nil }
struct View1State: Equatable { var view2State: View2State? = nil }
Since the states of the views are nested with one another, it is important to note that every state is nullable. The navigation in this architecture works when we initialize the state and deinitialize the state.
Initialize the state → navigate to the corresponding view.
Deinitialize the state → navigate back to the root view.
Figure 2
In figure 2, to navigate from View 1 to View 2, we initialize view2State. Similarly, to navigate back to View 1, we deinitialize view2State.
Let’s see how we can achieve this in code,
we have to create 3 actions,
Action to branch to the next view.
Let’s consider the following state
struct View1State: Equatable{ var view2State : View2State? = nil }
Let’s try creating the action for initialize and deinitialize.
enum View1Action { case presentView2 case didDismissView2 case view2(View2Action) }
Example 1
Let’s add the actions in the reducer,
let View1Reducer: Reducer<View1State, View1Action, View1Environment> = Reducer.combine( .init { state, action, environment in struct View1CancelId: Hashable {} switch action { case .presentView2: state.view2State = View2State() return .none case .didDismissView2: state.view2State = nil return .none case .view2(_): return .none } } )
Example 2
As we can see, we have handled the enum for presentView2 and didDismissView2. We will soon cover the need for having .view2(_).
It’s time to write some code in the view.
Usually, we would go for NavigationLink, we have created our extension to navigate using the states of the view.
.navigate( using: store.scope( state: \.view2State, action: View1Action.view2 ), destination: navigateToView2, onDismiss: { ViewStore(store.stateless).send(.didDismissView2) } )
Let’s break down the above code,
store.scope( state: \.view2State, action: View1Action.view2 )
Example 3
The above code uses a scope to get the Store of the child view from the parent view. Here, we get the Store of View2 from View1 using the scope. So, we can see that the purpose for creating case view2(View2Action) in Example 1 is to navigate from view1 to view2, we can also utilize this feature to a greater extent like passing data between views which we will look at in the upcoming pages.
destination: navigateToView2 expects a view with the corresponding Store it.
func navigateToView2(for store:Store<View2State, View2Action>) -> some View { // create and configure destination view here View2(store: store).navigationBarTitle("").navigationBarHidden(true) }
Notice in the above, we have used store which scop locally, there is a chance that we may run into null exception because view2State is nullable. To avoid crashes, we get the child store from the parent store
To get the child store, we can either go for Example 3 or
var view2Store: Store<View2State, View2Action> { return store.scope( state: { $0.view2State ?? View2State() }, action: view1Action.view2 ) }
and we can use this variable in View2(store: view2Store).
We have successfully initialized the view2State and the action view1Action.view2. But still, the architecture needs to know which reducer to look for in View2 i.e., view2Reducer and the environment.
The process where getting the child reducer from the parent reducer is known as PullBack.
PullBack can be achieved by,
View2Reducer.pullback( state: \View1State.prosState, action: /View1Action.pros, environment: { environment in View2Environment( view2Client: environment.view2Client, mainQueue: environment.mainQueue, uuid: environment.uuid ) } )
From the above code, we can get the reducer along with the corresponding action and environment.
NOTE: pullback by default, expects a Non-nullable State.
But, if we look back at example 1, we declare State as nullable var view2State: View2State? = nil
We have to add .optional() to the above code,
View2Reducer .optional() .pullback( state: \View1State.view2State, action: /View1Action.view2, environment: { environment in View2Environment( view2Client: environment.view2Client, mainQueue: environment.mainQueue, uuid: environment.uuid ) } )
.optional() enables us to use nullable state.
But when it comes to navigation, there is another way to get a child reducer from the parent reducer.
.presents( View2Reducer, state: \.view2State, action: /View1Action.view2, environment: { environment in View2Environment( view2Client: environment.view2Client, mainQueue: environment.mainQueue, uuid: environment.uuid ) } )
Example 4
NOTE: The above code expects only Nullable State.
In the above example, if we consider that we are in View1 and want to navigate to View2, then the above code should be written in the reducer of View1. Shown below
let View1Reducer: Reducer<View1State, View1Action, View1Environment> = Reducer.combine( .init { state, action, environment in struct View1CancelId: Hashable {} switch action { case .presentView2: state.view2State = View2State() return .none case .didDismissView2: state.view2State = nil return .none case .view2(_): return .none } } ).presents( View2Reducer, state: \.view2State, action: /View1Action.view2, environment: { environment in View2Environment( view2Client: environment.view2Client, mainQueue: environment.mainQueue, uuid: environment.uuid ) } )
Example 5
Let’s consider on a button click, we have to navigate from View1 to View2
Button(action: {viewStore.send(.presentView2)}){ Text("Sample Text") }
viewStore.send(.presentView2) this code initializes the state of the View2 which triggers the navigation.
What if we want to navigate back to View1?
We have to deinitialize the state of View2 staying in View2. But if we notice closely, the action to deinitialize the state of View2 is present in View1Reducer (Refer case .didDismissView2: in Example 5).
If we can access the parent view’s action from the child view’s action, we can able to access .didDismissView2 from View2Reducer.
Let’s consider we have an action and a reducer for View2 as shown below,
enum View2Action{
case navigateBackToView1
}
let View2Reducer: Reducer<View2State, View2Action, View2Environment> = Reducer.combine( .init { state, action, environment in struct View2CancelId: Hashable {} switch action { case .navigateBackToView1: return .none } } )
Now, we are going to access .didDismissView2 using .navigateBackToView1 which is in View2.
Remember, in example 2, we have a case .view2(_):.
Now, it’s time to dive deep into why we have case .view2(_): in View1Reducer. As we discussed earlier, it is used to connect between the views, i.e., in this example, the reducer of View1 is linked with the reducer of View2 with the help of case .view2(_):, and notice that there is a value associated to .view2 and it is unused which is (_) that returns View2Action, the unused value gives us all actions performed in View2. i.e., we are getting all actions performed on View2 in View1.
Instead of leaving this functionality unused, we can use this value and access the required action in View2.
We can add a case to access the action of View2 as shown case .view2(navigateBackToView1):
let View1Reducer: Reducer<View1State, View1Action, View1Environment> = Reducer.combine( .init { state, action, environment in struct View1CancelId: Hashable {} switch action { ... case .view2(navigateBackToView1): return .init(value: .didDismissView2) case .view2(_): return .none } } )
NOTE: Make sure that you write the default case case .view2(_):
at the end of the switch case.
Whenever we are trying to call navigateBackToView1
action from View2, case .view2(navigateBackToView1):
this case will also get triggered and in the above example we have added return .init(value: .didDismissView2)
which means we are redirecting from .view2(navigateBackToView1)
action to .didDismissView2
action.
Similarly, suppose we have View1….View10, we can access any action of View10 from View1, which means, we can navigate from View10 to View1.
case .view2(view3(...view10(.someAction))):
We can further use this functionality to send data between the views. We can look at it in upcoming pages.
That's all folks!