How to use SwiftUI + Coordinators

Transitioning from UIKit to SwiftUI can be both exciting and challenging. When I made a similar shift from Objective-C to Swift, I initially struggled by attempting to use Swift just like Objective-C, missing out on the unique advantages Swift had to offer. Learning from that experience, I was determined not to repeat the same mistake when moving from UIKit to SwiftUI.

At Pale Blue, we have been utilizing the MVVM (Model-View-ViewModel) + Coordinators software pattern with UIKit. Naturally, when I began working with SwiftUI, my initial impulse was to convert the existing UIKit logic directly to SwiftUI. However, it became apparent that this approach wasn’t feasible due to the fundamental differences between the two frameworks.

Realizing this, I paused to rethink and make sure that the Coordinators pattern, which worked well with UIKit, also fit well with SwiftUI. I began the process of adjusting and reshaping it to match the unique features and abilities of SwiftUI.

In this post, we will create a simple app that will have a login view, a forgot password, and a TabBar with 3 different views to make it look like a real-life case. For simplicity, we will not use MVVM for now.

Let's start with LoginView and ForgetPasswordView. We will not add real functionality to them but we will mimic their behavior.

import SwiftUI

struct LoginView: View {
    // In MVVM the Output will be located in the ViewModel
    
    struct Output {
        var goToMainScreen: () -> Void
        var goToForgotPassword: () -> Void
    }

    var output: Output

    var body: some View {
        Button(
            action: {
                self.output.goToMainScreen()
            },
            label: {
                Text("Login")
            }
        ).padding()

        Button(
            action: {
                self.output.goToForgotPassword()
            },
            label: {
                Text("Forgot password")
            }
        )
    }
}

#Preview {
    LoginView(output: .init(goToMainScreen: {}, goToForgotPassword: {}))
}

LoginView

import SwiftUI

struct ForgotPasswordView: View {
    // In MVVM the Output will be located in the ViewModel
    struct Output {
        var goToForgotPasswordWebsite: () -> Void
    }

    var output: Output

    var body: some View {
        Button(
            action: {
                self.output.goToForgotPasswordWebsite()
            },
            label: {
                Text("Forgot password")
            }
        ).padding()
    }
}

#Preview {
    ForgotPasswordView(output: .init(goToForgotPasswordWebsite: {}))
}

ForgotView

There's nothing particularly unique about these two views, except for the Output struct. The purpose behind the Output struct is to relocate the navigation logic away from the view. Even if it doesn't seem clear at the moment, you'll grasp its functionality when we get into the AuthenticationCoordinator below.

Since both are views related to "authentication," we'll create an AuthenticationCoordinator that will handle their construction and navigation.

import Foundation
import SwiftUI

enum AuthenticationPage {
    case login, forgotPassword
}

final class AuthenticationCoordinator: Hashable {
    @Binding var navigationPath: NavigationPath

    private var id: UUID
    private var output: Output?
    private var page: AuthenticationPage

    struct Output {
        var goToMainScreen: () -> Void
    }

    init(
        page: AuthenticationPage,
        navigationPath: Binding<NavigationPath>,
        output: Output? = nil
    ) {
        id = UUID()
        self.page = page
        self.output = output
        self._navigationPath = navigationPath
    }

    @ViewBuilder
    func view() -> some View {
        switch self.page {
            case .login:
                loginView()
            case .forgotPassword:
                forgotPasswordView()
        }
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (
        lhs: AuthenticationCoordinator,
        rhs: AuthenticationCoordinator
    ) -> Bool {
        lhs.id == rhs.id
    }

    private func loginView() -> some View {
        let loginView = LoginView(
            output:
                .init(
                    goToMainScreen: {
                        self.output?.goToMainScreen()
                    },
                    goToForgotPassword:  {
                        self.push(
                            AuthenticationCoordinator(
                                page: .forgotPassword,
                                navigationPath: self.$navigationPath
                            )
                        )
                    }
                )
        )
        return loginView
    }

    private func forgotPasswordView() -> some View {
        let forgotPasswordView = ForgotPasswordView(output:
                .init(
                    goToForgotPasswordWebsite: {
                        self.goToForgotPasswordWebsite()
                    }
                )
        )
        return forgotPasswordView
    }

    private func goToForgotPasswordWebsite() {
        if let url = URL(string: "https://www.google.com") {
            UIApplication.shared.open(url)
        }
    }

    func push<V>(_ value: V) where V : Hashable {
        navigationPath.append(value)
    }
}

AuthenticationCoordinator

A lot is happening within this context, so let's begin by examining the AuthenticationPage enum. Its role is to specify the page that the coordinator will initiate.

Moving on to the properties:

navigationPath: This property serves as a binding for NavigationPath and is injected from the AppCoordinator. Access to NavigationPath assists us in pushing our authentication views.

id: This represents a UUID assigned to each view, ensuring uniqueness during comparisons within the Hashable functions.

output: Similar to LoginView and ForgotView, Coordinators also possess an output. In the AuthenticationCoordinator, once the user is authenticated, transitioning to the main view becomes necessary. However, this transition is not the responsibility of the AuthenticatorCoordinator. Therefore, we utilize the output to inform the AppCoordinator that the authentication process is completed.

authenticationPage: This property's purpose is to define which page the coordinator will initialize.

Now let's examine the functions:

func view() -> some View: This function's role is to provide the appropriate view. It will be invoked within the navigationDestination of the NavigationStack, which is situated within our SwiftUI_CApp, which we'll explore later.

private func loginView() -> some View: This function returns the LoginView while also configuring its outputs.

private func forgotPasswordView() -> some View: Similar to the previous function, this returns the ForgotPasswordView while setting up its outputs.

private func goToForgotPasswordWebsite(): This function simulates opening a URL in Safari, resembling the action of accessing the forgot password webpage.

func push(_ value: V) where V: Hashable: This function appends a view to the NavigationPath provided in the initialization process.

Below is the AppCoordinator that we mentioned before. Its primary role is to serve as the main coordinator, responsible for initializing all other coordinators. To maintain simplicity, we'll encapsulate the SwiftUI components within a separate view called MainView.

import SwiftUI

final class AppCoordinator: ObservableObject {
    @Published var path: NavigationPath

    init(path: NavigationPath) {
        self.path = path
    }

    @ViewBuilder
    func view() -> some View {
        MainView()
    }
}

AppCoordinator

import SwiftUI

struct MainView: View {
    @EnvironmentObject var appCoordinator: AppCoordinator

    var body: some View {
        Group {
            AuthenticationCoordinator(
                page: .login,
                navigationPath: $appCoordinator.path,
                output: .init(
                    goToMainScreen: {
                        print("Go to main screen (MainTabView)")
                    }
                )
            ).view()
        }
    }
}

#Preview {
    MainView()
}

MainView

In MainView, we use the AppCoordinator through EnvironmentObject to pass it to other coordinators for handling navigation. Also, use the AuthenticationCoordinator's output feature to switch from LoginView to MainView once the user logs in.

import SwiftUI

@main
struct SwiftUI_CApp: App {
    @StateObject private var appCoordinator = AppCoordinator(path: NavigationPath())

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $appCoordinator.path) {
                appCoordinator.view()
                    .navigationDestination(
                        for: AuthenticationCoordinator.self
                    ) { coordinator in
                        coordinator.view()
                    }
            }
            .environmentObject(appCoordinator)
        }
    }
}

SwiftUI_CApp

In SwiftUI_CApp is where everything comes together. We begin by setting up appCoordinator using the @StateObject wrapper, to ensure its persistence during view updates. Next, a NavigationStack is created and supplied with the NavigationPath from AppCoordinator. This enables navigation as views are added or removed within the Coordinators. After constructing the AppCoordinator view, a navigationDestination is established for AuthenticationCoordinator. Lastly, we inject appCoordinator into the NavigationStack, making it available for all the views inside the stack.

And that's it for Part 1. In Part 2 we will wire up the Login/Logout logic and add a MainTabView simulating a real-case scenario,

You can find the source code here.