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.
The big difference in the responsibilities of the Coordinators in SwiftUI is that we only have one NavigationStack (in contrast with UIKit) and that NavigationStack will handle every navigation using its NavigationPath parameter.
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.
Top comments (0)