The Composable Architecture II
Navigation in TCA
The Composable Architecture II
Navigation in TCA
0
0
Checkbox to mark video as read
Mark as read

Navigation in SwiftUI can be challenging, especially when building complex apps with dynamic requirements. The TCA (The Composable Architecture) framework simplifies state management and enables organized navigation patterns like tree-based and stack-based navigation while handling dismissals efficiently.

In this article we are going to see how to create the same navigation in both patterns, tree-based and stack-based, how to test it, and finally how to dismiss programmatically.

You can find the Example project on GitHub, where each navigation pattern has been placed in a different target.

Each of this navigation patterns will contain the same screens:

  • MainMenu: The main view that shows two buttons, one to go to the SignIn screen and another one to go to the SignUp screen.
  • SignIn: Nothing special, just a sample screen.
  • SignUp: Here we'll find a button that will simulate the end of the sign up, opening SignUpConfirmation screen.
  • SignUpConfirmation: Nothing special, just a sample screen.


Tree-Based Navigation

In this navigation pattern, each view presents the next. The advantage is avoiding all the code in one place, but the drawback is losing centralized flow control.

In order to manage view presentation we'll add Destination enum to our reducer. Each case will describe the view that we want to present.

enum Destination {
    case signIn(SignIn)
    case signUp(SignUp)
}

Then we can add the state and the action for this:

@ObservableState
struct State {
    @Presents var destination: Destination.State?
}

enum Action {
    case signInTapped
    case signUpTapped

    case destination(PresentationAction<Destination.Action>)
}

Now we can manage not only the MainMenu actions, but also the presented views actions:

var body: some ReducerOf<MainMenu> {
    Reduce { state, action in
        switch action {
        case .signInTapped:
            state.destination = .signIn(SignIn.State())
            return .none
        case .signUpTapped:
            state.destination = .signUp(SignUp.State())
            return .none

        // You can catch specific child actions like this
        case .destination(.presented(.signUp(.signUpTapped))):
            return .none
        // The rest of the children action will trigger this
        case .destination:
            return .none
        }
    }
}

To finish with the reducer, we need to add the scope of all the destinations with just one line.

var body: some ReducerOf<MainMenu> {
    Reduce { state, action in
        ...
    }
    .ifLet(\.$destination, action: \.destination) // <-- THIS LINE
}

Finaly, we only need to set the NavigationStack with all the destinations in our view.

var body: some View {
    NavigationStack {
        VStack {
            // CONTENT OF THE VIEW
        }
        .padding(20)
        .navigationDestination(
            item: $store.scope(
                state: \.destination?.signIn,
                action: \.destination.signIn
            )
        ) { store in
            SignInView(store: store)
        }
        .navigationDestination(
            item: $store.scope(
                state: \.destination?.signUp,
                action: \.destination.signUp
            )
        ) { store in
            SignUpView(store: store)
        }
    }
}


As you can see, for presenting a new screen we just need to assign a Destination's case to the state's destination. Here we can inject any data we need in the new screen's state.

Stack-Based Navigation

For Stack-based navigation we are going to manage the whole flow from one screen, the MainMenu, which acts as Coordinator.

First, we need to declare the Path and its cases:

enum Path {
    case signIn(SignIn)
    case signUp(SignUp)
    case signUpConfirmation(SignUpConfirmation)
}

Then, we add the state and the actions:

struct State {
    var path = StackState<Path.State>()
}

enum Action {
    case signInTapped
    case signUpTapped

    case path(StackActionOf<Path>)
}

To finish with the reducer, we just handle the actions, and add all the Path cases scopes with a forEach function.

var body: some ReducerOf {
    Reduce { state, action in
        switch action {
        case .signInTapped:
            state.path.append(.signIn(SignIn.State()))
            return .none
        case .signUpTapped:
            state.path.append(.signUp(SignUp.State()))
            return .none

        // You can catch specific child actions like this
        case .path(.element(id: _, action: .signUp(.signUpTapped))):
            state.path.append(.signUpConfirmation(SignUpConfirmation.State()))
            return .none

        // The rest of the children action will trigger this
        case .path:
            return .none
        }
    }
    .forEach(\.path, action: \.path)
}

As you can see, for presenting a new view, we just need to append a new Path case to the state's path. This will include state of that screen, which allows to inject any data we need in there.

Finally, we set the NavigationStack in our view, and define all the destinations:

var body: some View {
    NavigationStack(
        path: $store.scope(state: \.path, action: \.path)
    ) {
        // CONTENT OF THE VIEW
    } destination: { store in
        switch store.case {
        case .signIn(let signInStore):
            SignInView(store: signInStore)
        case .signUp(let signUpStore):
            SignUpView(store: signUpStore)
        case .signUpConfirmation(let confirmationStore):
            SignUpConfirmationView(store: confirmationStore)
        }
    }
}

Handling Dismissals

We can dismiss views programmatically setting destination to nil or removing the last item of the path. This can be done only from the presenter screen, however, we can trigger a dismiss from the presented views using the dismiss dependency.

We just need to add it to our reducer:

@Reducer
struct MainMenu {
    @Dependency(\.dismiss) var dismiss

    // ...
}

And then trigger it with an effect when we need it:

case .exitButtonTapped:
    return .run { _ in await self.dismiss() }

Testing Navigation

In order to be able to test our Navigation, we must make all our screen's State, Action, Destination and Path to conform Equatable protocol.

struct State: Equatable {
    // ...
}

struct Action: Equatable {
    // ...
}

@Reducer(state: .equatable, action: .equatable)
enum Destination {
    // ...
}

@Reducer(state: .equatable, action: .equatable)
enum Path {
    // ...
}

Then we will be able to create unit tests:

For Tree-based navigation.

@Test func mainMenuNavigation() async {
    let store = TestStore(initialState: MainMenu.State()) {
        MainMenu()
    }

    // when click sign in button
    await store.send(.signInTapped) {
        // then SignIn view is presented
        $0.destination = .signIn(SignIn.State())
    }

    // when click sign up button
    await store.send(.signUpTapped) {
        // then SignUp view is presented
        $0.destination = .signUp(SignUp.State())
    }
}

@Test func signUpNavigation() async {
    let store = TestStore(initialState: SignUp.State()) {
        SignUp()
    }

    // when click sign in button
    await store.send(.signUpTapped) {
        // then Sign In view is presented
        $0.destination = .confirmation(SignUpConfirmation.State())
    }
}

And for Stack-based navigation.

@Test func signInPath() async {
    let store = TestStore(initialState: MainMenu.State()) {
        MainMenu()
    }

    // when click sign in button
    await store.send(.signInTapped) {
        // then SignIn view is presented
        $0.path.append(.signIn(SignIn.State()))
    }
}

@Test func signUpPath() async {
    let store = TestStore(initialState: MainMenu.State()) {
        MainMenu()
    }

    // when click sign up button
    await store.send(.signUpTapped) {
        // then SignUp view is presented
        $0.path.append(.signUp(SignUp.State()))
    }

    // when click sign up button
    await store.send(.path(.element(
        id: StackElementID(integerLiteral: 0),
        action: .signUp(.signUpTapped)
    ))) {
        // then SignUpConfirmation view is presented
        $0.path.append(.signUpConfirmation(SignUpConfirmation.State()))
    }
}

0 Comments

Join the community to comment

Be the first to comment

Accept Cookies

We use cookies to collect and analyze information on site performance and usage, in order to provide you with better service.

Check our Privacy Policy