How to build UIKit like MVVM-C Coordinator hierarchy with SwiftUI

Tharindu Ramesh Ketipearachchi
13 min readMay 14, 2023

--

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 @Stateobject 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 !

--

--

Tharindu Ramesh Ketipearachchi

Technical Lead (Swift, Objective C, Flutter, react-native) | iOS Developer | Mobile Development Lecturer |MSc in CS, BSc in CS (Col)