How to build UIKit like MVVM-C Coordinator hierarchy with SwiftUI
Ever since SwiftUI was introduced, It’s been a struggle to build MVVM-C using SwiftUI. The whole idea behind introducing MVVM-C over MVVM to separate app’s routing logic from the view layer. In UIKit we can do it perfectly since we can scratch the UINavigationController out of the ViewController and use it separately. But this isn’t possible with SwiftUI hence the view and the Navigation are hardly bound together.
But after SwiftUI has introduced updates to NavigationStack using new NavigationPath variable on iOS 16 onwards, we have some flexibility to separate NavigationStack from the view on SwiftUI as well. This update allows us to implement UIKit like Coordinator pattern on our SwiftUI projects.
What are we gonna build today?
We are going to implement following complex navigation hierarchy today. Home page contains links to three main flows (Users, Profile, Settings) and each flow contains 2,3 navigations to their child screens.
What’s the old approach to use Coordinator with SwiftUI
Most famous approach to use Coordinator pattern with SwiftUI is to use single coordinator throughout the whole application as an observable object. Here, we are only using coordinator for navigation purpose and we aren’t using coordinator to handle other responsibilities which we used to do in UIKit MVVM-C Coordinator. This approach has following drawbacks.
- Single coordinator responsible for handling all the navigations.
- Coordinator becomes too heavy when the app is scaling.
- Coordinator knows too much and hard to modularise the app.
- Becomes less readable & hard to maintain when the app grows.
What’s the new approach here?
We are going to follow a more UIKit like approach here.
We’ll be creating a main AppCoordinator
object attached to ContentView
class of our app. Then we’ll be creating separate coordinators for each navigation flow. (User Flow, Settings Flow, Profile Flow). The flow coordinator should be responsible for handle navigations of that flow.
NavigationStack & NavigationPath
NavigationStack
is the component that SwiftUI provided for stack based navigations.
struct ContentView: View {
@State private var path: [Item] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetailsView(item: item)
}
}
}
}
As you have seen in above code, you can pass an array as a @State
object to NavigationStack
. Once you append an item to this array, It will be adding a new screen to theNavigationStack
, just like we push a new ViewController
to UINavigationController
in UIKit. This item can be passed to the particular screen using the navigationDestination(for:)
method and can be accessed from that particular screen as well.
The problem with this approach is in reality, we don’t pass same type of object to all the screens in our app. We have to pass different type of objects to different screens with different views. That’s why Apple has introduced new type call NavigationPath
. This is a type that allows us to store any type of data. Using this we can append heterogeneous elements to our path array. The only concern is that all those element types should to conform to Hashable
protocol. Now we can define our path variable as follows.
@State private var path = NavigationPath()
For more about SwiftUI navigations, Please read this.
Let’s code . . .
Well enough theory for now. Let’s start coding. Let’s create a SwiftUI single page application first. Our CoordinatorApp.swift
file looks follows.
import SwiftUI
@main
struct CoordinatorApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Our ContentView.swift
file looks as follows.
import SwiftUI
struct ContentView: View {
@StateObject private var appCoordinator = AppCoordinator(path: NavigationPath())
var body: some View {
NavigationStack(path: $appCoordinator.path) {
appCoordinator.build()
.navigationDestination(for: UserFlowCoordinator.self) { coordinator in
coordinator.build()
}
.navigationDestination(for: SettingsFlowCoordinator.self) { coordinator in
coordinator.build()
}
.navigationDestination(for: ProfileFlowCoordinator.self) { coordinator in
coordinator.build()
}
}
.environmentObject(appCoordinator)
}
}
Here you can see we have passed NavigationPath()
object to our AppCoordinator.
The StateObject
instance of the AppCoordinator
has been passed to NavigationStack
as an EnvironmentObject
. We are going to append FlowCoordinators
to push new screens. We’ll be explaining those things one by one later.
AppCoordinator
AppCoordinator
is the main coordinator class of our app. It stored the NavigationPath()
variable and keep the links to all the FlowCoordinators
and manage them as well.
import SwiftUI
import Combine
final class AppCoordinator: ObservableObject {
@Published var path: NavigationPath
private var cancellables = Set<AnyCancellable>()
init(path: NavigationPath) {
self.path = path
}
@ViewBuilder
func build() -> some View {
homeView()
}
private func push<T: Hashable>(_ coordinator: T) {
path.append(coordinator)
}
private func homeView() -> some View {
let homeView = HomeView()
bind(view: homeView)
return homeView
}
// MARK: Flow Control Methods
private func usersFlow() {
let usersFlowCoordinator = UserFlowCoordinator(page: .users)
self.bind(userCoordinator: usersFlowCoordinator)
self.push(usersFlowCoordinator)
}
private func settingsFlow() {
let settingsFlowCoordinator = SettingsFlowCoordinator(page: .main)
self.bind(settingsCoordinator: settingsFlowCoordinator)
self.push(settingsFlowCoordinator)
}
private func profileFlow() {
let profileFlowCoordinator = ProfileFlowCoordinator(page: .main)
self.bind(profileCoordinator: profileFlowCoordinator)
self.push(profileFlowCoordinator)
}
// MARK: HomeView Bindings
private func bind(view: HomeView) {
view.didClickMenuItem
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] item in
switch item {
case "Users":
self?.usersFlow()
case "Settings":
self?.settingsFlow()
case "Profile":
self?.profileFlow()
default:
break
}
})
.store(in: &cancellables)
}
// MARK: Flow Coordinator Bindings
private func bind(userCoordinator: UserFlowCoordinator) {
userCoordinator.pushCoordinator
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] coordinator in
self?.push(coordinator)
})
.store(in: &cancellables)
}
private func bind(settingsCoordinator: SettingsFlowCoordinator) {
settingsCoordinator.pushCoordinator
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] coordinator in
self?.push(coordinator)
})
.store(in: &cancellables)
}
private func bind(profileCoordinator: ProfileFlowCoordinator) {
profileCoordinator.pushCoordinator
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] coordinator in
self?.push(coordinator)
})
.store(in: &cancellables)
}
}
In our AppCoordinator
file, first we have our build()
method and we are returning our HomeView
here. This will works as the RootViewController
of our stack. That’s why we have called appCoordinator.build()
on our ContentView
as the rootView.
Following is our HomeView.swift
file.
import SwiftUI
import Combine
struct HomeView: View {
let didClickMenuItem = PassthroughSubject<String, Never>()
@State var menuItems = ["Users", "Settings", "Profile"]
var body: some View {
NavigationView {
List {
ForEach(menuItems, id: \.self) { item in
Button(action: {
didClickMenuItem.send(item)
}) {
Text(item)
}
}
}
.navigationBarTitle("MVVMC DEMO")
}
}
}
Here we have three links for our main navigation flows. Those are Users, Settings and Profile. Each will make a trigger on our AppCoordinator
class using didClickMenuItem
.
The push()
method is being used to append any type of Coordinator
to path
variable and push a new screen to our NavigationStack
.
Flow control methods basically create new FlowCoordinators
for each flow and do the necessary bindings.
In the bind()
method for our HomeView
, we checked the button clicks and call the relevant navigationFlow
creation method for each click.
Let’s build the UserFlow
Now we are going to build our UserFlow
. Here I want to show you exactly how to do it in MVVM-C way. So I’m using ViewModels
to populate data in each screen of this flow. The flow consists with two screen. First is the UsersList screen which shows the list of users. Once we click any user item on that list, we will be redirected to UserDetails screen where we can see the details of that user. So the file structure of user flow looks follows.
Here is our UsersListView
file.
import SwiftUI
import Combine
struct UsersListView: View {
@StateObject var viewModel: UsersListViewModel
let didClickUser = PassthroughSubject<User, Never>()
var body: some View {
NavigationView {
List(viewModel.users) { user in
Button(action: {
didClickUser.send(user)
}) {
Text(user.name)
}
}
.navigationBarTitle("Users")
.onAppear {
viewModel.fetchUsers()
}
}
}
}
This will get the users array from UsersListViewModel
and shows as a list. The fetchUsers()
function of the viewModel
being called on onAppear()
method.
This is how our UsersListViewModel
file looks like.
import Combine
final class UsersListViewModel: ObservableObject {
@Published var users: [User] = []
func fetchUsers() {
self.users = [
User(id: 1, name: "User 1"),
User(id: 2, name: "User 2"),
User(id: 3, name: "User 3")
]
}
}
Here I have returned some dummy data on fetchUsers()
call. But you can make a network call here. Following is our User
data struct.
import Foundation
struct User: Identifiable {
let id: Int
let name: String
}
Following is our UserFlowCoordinator
class.
import SwiftUI
import Combine
// Enum to identify User flow screen Types
enum UserPage: String, Identifiable {
case users, profile
var id: String {
self.rawValue
}
}
final class UserFlowCoordinator: ObservableObject, Hashable {
@Published var page: UserPage
private var id: UUID
private var userID: Int?
private var cancellables = Set<AnyCancellable>()
let pushCoordinator = PassthroughSubject<UserFlowCoordinator, Never>()
init(page: UserPage, userID: Int? = nil) {
id = UUID()
self.page = page
if page == .profile {
guard let userID = userID else {
fatalError("userID must be provided for profile type")
}
self.userID = userID
}
}
@ViewBuilder
func build() -> some View {
switch self.page {
case .users:
usersListView()
case .profile:
userDetailsView()
}
}
// MARK: Required methods for class to conform to Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: UserFlowCoordinator, rhs: UserFlowCoordinator) -> Bool {
return lhs.id == rhs.id
}
// MARK: View Creation Methods
private func usersListView() -> some View {
let viewModel = UsersListViewModel()
let usersListView = UsersListView(viewModel: viewModel)
bind(view: usersListView)
return usersListView
}
private func userDetailsView() -> some View {
let viewModel = UserDetailsViewModel(userID: userID ?? 0)
let userDetailsView = UserDetailsView(viewModel: viewModel)
return userDetailsView
}
// MARK: View Bindings
private func bind(view: UsersListView) {
view.didClickUser
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] user in
self?.showUserProfile(for: user)
})
.store(in: &cancellables)
}
}
// MARK: Navigation Related Extensions
extension UserFlowCoordinator {
private func showUserProfile(for user: User) {
pushCoordinator.send(UserFlowCoordinator(page: .profile, userID: user.id))
}
}
To each screen type of the flow, we are appending a new instance of UserFlowCoordinator
to our path
variable. So we have kept a property called page: UserPage
to identify the particular screen. So we have created an enum called UserPage
and add all the screen types that we are gonna use on this flow (Users, Profile). If you are confused about returning a fatalError()
inside the init()
method, I have written a separate article on effective usage of fatalError. Please read it first.
The build()
method will return a relevant view according to the page type. Since we are going to append coordinator to the path variable, our coordinator should conform to Hashable
protocol. To do that, we had to implemented hash(into hasher:)
and static func == (lhs: UserFlowCoordinator, rhs: UserFlowCoordinator) -> Bool
methods.
View creation methods are responsible for instantiate and return relevant views in the flow. Here you can see we have used this FlowCoordinator
class to pass dependencies to our view as well. We have created relevant ViewModel
instances and passed it to View
using it’s initialiser
. If you want to pass any other dependencies such as APIClient
objects or any other type, you will be able to do it from here.
bind()
method to the UserListView
will trigger the button clicks on our Flow coordinator class. It’ll pass us the relevant user
object as well. In our showUserProfile(for user:)
method, we are creating a new UserFlowCoordinator
object with id
of the particular user and the .profile
as a page type. Then we’ll pass it to our AppCoordinator
class via pushCoodinator
binding. This will trigger the push()
method of our AppCoordinator
class and will append our new UserFlowCoordinator
object to the path
variable. It’ll push us a new UserDetails
screen on our NavigationStack
.
This is how you should pass data between your screens on this approach. You can declare a property on you Flow coordinator, pass the value at the initialisation and append it to the path
variable. Here you can see we are just using this userID
only on our UserDetails
screen. So we have made userID
an optional parameter of our Flow initialiser method by setting a default value to it.
Following is how our UserDetails files looks like.
import SwiftUI
struct UserDetailsView: View {
@StateObject var viewModel: UserDetailsViewModel
var body: some View {
VStack {
Text(viewModel.profile?.name ?? "N/A")
.font(.title)
if let age = viewModel.profile?.age {
Text("Age: \(String(age))")
} else {
Text("Age: Unknown")
}
Text("Occupation: \(viewModel.profile?.occupation ?? "N/A")")
Spacer()
}
.padding()
.navigationBarTitle("USER DETAILS")
.onAppear {
viewModel.fetchProfile()
}
}
}
import Combine
final class UserDetailsViewModel: ObservableObject {
@Published var profile:Profile?
private var userID: Int
init(userID: Int) {
self.userID = userID
}
func fetchProfile() {
self.profile = Profile(id: 05,
name: "Jone Doe",
age: 25,
occupation: "Doctor")
}
}
import Foundation
struct Profile: Identifiable {
let id: Int
let name: String
let age: Int
let occupation: String
}
Build the Settings Flow
In User Flow, I have explained how to use the MVVM-C architecture properly with this approach. I have explained how to pass dependencies and data between different screens as well.
On the Settings Flow and Profile Flow, I just need to show you the navigations. I’m not going to use any ViewModels
or data passing here. I’ve just created some dummy screen and going to push those in to our NavigationStack.
Following is how our Settings Flow file structure looks like.
Looks how our SettingsFlowCoordinator
file looks.
import SwiftUI
import Combine
// Enum to identify Settings flow screen Types
enum SettingsPage: String, Identifiable {
case main, privacy, custom
var id: String {
self.rawValue
}
}
final class SettingsFlowCoordinator: ObservableObject, Hashable {
@Published var page: SettingsPage
private var id: UUID
private var cancellables = Set<AnyCancellable>()
let pushCoordinator = PassthroughSubject<SettingsFlowCoordinator, Never>()
init(page: SettingsPage) {
id = UUID()
self.page = page
}
@ViewBuilder
func build() -> some View {
switch self.page {
case .main:
mainSettingsView()
case .privacy:
privacySettingsView()
case .custom:
customSettingsView()
}
}
// MARK: Required methods for class to conform to Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: SettingsFlowCoordinator, rhs: SettingsFlowCoordinator) -> Bool {
return lhs.id == rhs.id
}
// MARK: View Creation Methods
private func mainSettingsView() -> some View {
let mainView = MainSettingsView()
bind(view: mainView)
return mainView
}
private func privacySettingsView() -> some View {
return PrivacySettingsView()
}
private func customSettingsView() -> some View {
return CustomSettingsView()
}
// MARK: View Bindings
private func bind(view: MainSettingsView) {
view.didClickPrivacy
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] didClick in
if didClick {
self?.showPrivacySettings()
}
})
.store(in: &cancellables)
view.didClickCustom
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] didClick in
if didClick {
self?.showCustomSettings()
}
})
.store(in: &cancellables)
}
}
// MARK: Navigation Related Extensions
extension SettingsFlowCoordinator {
private func showPrivacySettings() {
pushCoordinator.send(SettingsFlowCoordinator(page: .privacy))
}
private func showCustomSettings() {
pushCoordinator.send(SettingsFlowCoordinator(page: .custom))
}
}
I’m not going to explain this class here. It looks pretty similar to our UserFlowCoordinator
. We have used different enum called SettingsPage
to identify screen types here. Following is our MainSettingsView
file and it has navigation links to Privacy Settings & Custom Settings screens
import SwiftUI
import Combine
struct MainSettingsView: View {
let didClickPrivacy = PassthroughSubject<Bool, Never>()
let didClickCustom = PassthroughSubject<Bool, Never>()
var body: some View {
List {
Button(action: {
didClickPrivacy.send(true)
}) {
Text("Privacy Settings")
}
Button(action: {
didClickCustom.send(true)
}) {
Text("Custom Settings")
}
}
.navigationBarTitle("Settings")
}
}
Building the Profile Flow
This is pretty similar to Settings Flow. We have some dummy screens here. Following is how our Profile Flow file structure looks like.
Following is how our ProfileFlowCoordinator
looks.
import SwiftUI
import Combine
// Enum to identify Profile flow screen Types
enum ProfilePage: String, Identifiable {
case main, personal, education
var id: String {
self.rawValue
}
}
final class ProfileFlowCoordinator: ObservableObject, Hashable {
@Published var page: ProfilePage
private var id: UUID
private var cancellables = Set<AnyCancellable>()
let pushCoordinator = PassthroughSubject<ProfileFlowCoordinator, Never>()
init(page: ProfilePage) {
id = UUID()
self.page = page
}
@ViewBuilder
func build() -> some View {
switch self.page {
case .main:
mainProfileView()
case .personal:
personalDetailsView()
case .education:
educationDetailsView()
}
}
// MARK: Required methods for class to conform to Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ProfileFlowCoordinator, rhs: ProfileFlowCoordinator) -> Bool {
return lhs.id == rhs.id
}
// MARK: View Creation Methods
private func mainProfileView() -> some View {
let mainView = MainProfileView()
bind(view: mainView)
return mainView
}
private func personalDetailsView() -> some View {
return PersonalDetailsView()
}
private func educationDetailsView() -> some View {
return EducationalDetailsView()
}
// MARK: View Bindings
private func bind(view: MainProfileView) {
view.didClickPersonal
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] didClick in
if didClick {
self?.showPersonalDetails()
}
})
.store(in: &cancellables)
view.didClickEducation
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] didClick in
if didClick {
self?.showEducationDetails()
}
})
.store(in: &cancellables)
}
}
// MARK: Navigation Related Extensions
extension ProfileFlowCoordinator {
private func showPersonalDetails() {
pushCoordinator.send(ProfileFlowCoordinator(page: .personal))
}
private func showEducationDetails() {
pushCoordinator.send(ProfileFlowCoordinator(page: .education))
}
}
Here is how our MainProfileView
screen looks.
import SwiftUI
import Combine
struct MainProfileView: View {
let didClickPersonal = PassthroughSubject<Bool, Never>()
let didClickEducation = PassthroughSubject<Bool, Never>()
var body: some View {
List {
Button(action: {
didClickPersonal.send(true)
}) {
Text("Personal Details")
}
Button(action: {
didClickEducation.send(true)
}) {
Text("Educational Details")
}
}
.navigationBarTitle("Profile")
}
}
Here, all the things are pretty similar to Settings Flow. I don’t think you would need a separate explanation here.
Final Project & Full Source Code
Here is how our full project structure looks after the completion of the project.
You’ll be able to download the full project source code on my Github page.
Conclusion
There are a lots of benefits on this approach compared to single coordinator approach with SwiftUI. Those are,
- Easier to scale and modularise the app by adding different flow coordinators.
- You can further break down the flows by adding more sub coordinators.
- Main coordinator doesn’t become too heavy to handle.
- Each coordinator only knows about navigations of its own flow only. This will make easier to maintain the the navigations of the each flow.
- Becomes more readable & easy to maintain when the app grows.
This approach is open to your thoughts as well. If you see anything to be improved or changed on this approach, just let me know via comments.
Let’s improve this together. Have a nice coding !