The MVVM-C pattern, which combines the Model-View-ViewModel (MVVM) architecture with a Coordinator layer, offers a structured approach to building scalable and maintainable iOS apps. It effectively separates concerns, making the codebase more modular and easier to test.

In this tutorial, we will implement a sample focused solely on its navigation components. At the end of the post, you will find the GitHub repository where you can access the sample project used for this tutorial.

The coordinator component

In the MVVM-C (Model-View-ViewModel-Coordinator) pattern, the Coordinator is responsible for managing navigation and application flow, ensuring that the View and ViewModel remain focused on UI presentation and business logic, respectively, without being concerned with navigation and flow management. It handles the creation and configuration of View and ViewModel instances, determines which screen to display next based on user actions or app logic, and manages transitions between screens. By centralizing navigation logic, the Coordinator promotes modularity, reusability, and testability, maintaining a clean and scalable architecture.

Depending on the complexity of the app, the Coordinator can be implemented in different ways:

  • Whole App Coordinator – Best for small apps with a few screens, where a single component can effectively manage the navigation flow.
  • Flow Coordinator – In larger apps, a single coordinator becomes difficult to manage. Grouping screens by business flow improves modularity and maintainability.
  • Screen Coordinator – Each screen has its own dedicated coordinator, making it useful for reusable components, such as a payment screen appearing in different user journeys. This approach is often used in architectures like VIPER, where each module operates independently.

Ultimately, the choice of implementation depends on the app’s complexity and business requirements; no single pattern fits all use cases.

The sample app

The app we are going to implement is a Tab View app. Each tab represents a different navigation flow:

flowtab1
Screenshot

The First Tab Flow is a flow coordinator that presents a Primary View with two buttons. These buttons navigate to either Secondary View 1 or Secondary View 2.

  • When Secondary View 2 appears, it includes a button that allows navigation to Tertiary View 1.
  • In Tertiary View 1, there is a button that returns directly to the Primary View or allows navigation back to the previous screen using the back button.
  • Secondary View 2 does not lead to any additional views; users can only return to the previous screen using the back button.

The Second Tab Flow is managed by a Screen Coordinator, which presents a single screen with a button that opens a view model.

  • In this context, we consider the modal to be part of the view.
  • However, depending on the app’s design, the modal might instead be managed by the coordinator.

Main Tab View

This is the entry view point from the app:

struct MainView: View {
    @StateObject private var tab1Coordinator = Tab1Coordinator()
    @StateObject private var tab2Coordinator = Tab2Coordinator()

    var body: some View {
        TabView {
            NavigationStack(path: $tab1Coordinator.path) {
                tab1Coordinator.build(page: .primary)
                    .navigationDestination(for: Tab1Page.self) { page in
                        tab1Coordinator.build(page: page)
                    }
            }
            .tabItem {
                Label("Tab 1", systemImage: "1.circle")
            }

            NavigationStack(path: $tab2Coordinator.path) {
                tab2Coordinator.build(page: .primary)
                    .navigationDestination(for: Tab2Page.self) { page in
                        tab2Coordinator.build(page: page)
                    }
            }
            .tabItem {
                Label("Tab 2", systemImage: "2.circle")
            }
        }
    }
}

The provided SwiftUI code defines a MainView with a TabView containing two tabs, each managed by its own coordinator (Tab1Coordinator and Tab2Coordinator). Each tab uses a NavigationStack bound to the coordinator’s path property to handle navigation. The coordinator’s build(page:) method constructs the appropriate views for both the root (.primary) and subsequent pages.

The navigationDestination(for:) modifier ensures dynamic view creation based on the navigation stack, while the tabItem modifier sets the label and icon for each tab. This structure effectively decouples navigation logic from the view hierarchy, promoting modularity and ease of maintenance.

Another key aspect is selecting an appropriate folder structure. The one I have chosen is as follows:

Screenshot

This may not be the best method, but it follows a protocol to prevent getting lost when searching for files.

The flow coordinator

The first structure we need to create is an enum that defines the screens included in the flow:

enum Tab1Page: Hashable {
    case primary
    case secondary1
    case secondary2
    case tertiary
}

Hashable is not free; we need to push and pop those cases into a NavigationPath. The body of the coordinator is as follows:

class Tab1Coordinator: ObservableObject {
    @Published var path = NavigationPath()

    func push(_ page: Tab1Page) {
        path.append(page)
    }

    func pop() {
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    @ViewBuilder
       func build(page: Tab1Page) -> some View {
           switch page {
           case .primary:
               Tab1PrimaryView(coordinator: self)
           case .secondary1:
               Tab1SecondaryView1(coordinator: self)
           case .secondary2:
               Tab1SecondaryView2()
           case .tertiary:
               Tab1TertiaryView(coordinator: self)
           }
       }
}

The Tab1Coordinator class is an ObservableObject that manages navigation within a SwiftUI view hierarchy for a specific tab (Tab1). It uses a NavigationPath to track the navigation stack, allowing views to be pushed onto or popped from the stack through methods such as push(_:), pop(), and popToRoot(). The @Published property path ensures that any changes to the navigation stack are automatically reflected in the UI.

The build(page:) method, marked with @ViewBuilder, constructs and returns the appropriate SwiftUI view (e.g., Tab1PrimaryView, Tab1SecondaryView1, Tab1SecondaryView2, or Tab1TertiaryView) based on the provided Tab1Page enum case. This approach enables dynamic navigation between views while maintaining a clean separation of concerns.

The last section of the coordinator is the protocol implementation for the views presented by the coordinator. When a view has completed its work, it delegates the decision of which screen to present next to the coordinator. The coordinator is responsible for managing the navigation logic, not the view.

extension Tab1Coordinator: Tab1PrimaryViewProtocol {
    func goToSecondary1() {
        push(.secondary1)
    }
    func goToSecondary2() {
        push(.secondary2)
    }
}

extension Tab1Coordinator: Tab1SecondaryView1Protocol {
    func goToTertiaryView() {
        push(.tertiary)
    }
}

extension Tab1Coordinator: Tab1TertiaryViewProtocol {
    func backToRoot() {
        self.popToRoot()
    }
}

This is the code from one of the views:

import SwiftUI

protocol Tab1PrimaryViewProtocol: AnyObject {
    func goToSecondary1()
    func goToSecondary2()
}

struct Tab1PrimaryView: View {
     let coordinator: Tab1PrimaryViewProtocol
    
        var body: some View {
            
            VStack {
                Button("Go to Secondary 1") {
                    coordinator.goToSecondary1()
                }
                .padding()

                Button("Go to Secondary 2") {
                    coordinator.goToSecondary2()
                }
                .padding()
            }
            .navigationTitle("Primary View")
        }
}

When the view doesn’t know how to proceed, it should call its delegate (the Coordinator) to continue.

The screen coordinator

The first structure we need to create is an enum that defines the screens in the flow:

enum Tab2Page: Hashable {
    case primary
}

class Tab2Coordinator: ObservableObject {
    @Published var path = NavigationPath()
    
    @ViewBuilder
    func build(page: Tab2Page) -> some View {
        switch page {
        case .primary:
            Tab2PrimaryView(coordinator: self)
        }
    }
}

Hashable is not free; we need to push/pop these cases into a NavigationPath. The body of the coordinator is simply as follows:

class Tab1Coordinator: ObservableObject {
    @Published var path = NavigationPath()

    func push(_ page: Tab1Page) {
        path.append(page)
    }

    func pop() {
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    @ViewBuilder
       func build(page: Tab1Page) -> some View {
           switch page {
           case .primary:
               Tab1PrimaryView(coordinator: self)
           case .secondary1:
               Tab1SecondaryView1(coordinator: self)
           case .secondary2:
               Tab1SecondaryView2()
           case .tertiary:
               Tab1TertiaryView(coordinator: self)
           }
       }
}

The provided code defines a SwiftUI-based navigation structure for a tabbed interface. The Tab2Coordinator class is an ObservableObject that manages the navigation state using a NavigationPath, which is a state container for navigation in SwiftUI. The @Published property path allows the view to observe and react to changes in the navigation stack. The build(page:) method is a ViewBuilder that constructs the appropriate view based on the Tab2Page enum case. Specifically, when the page is .primary, it creates and returns a Tab2PrimaryView, passing the coordinator itself as a dependency.

This approach is commonly used in SwiftUI apps to handle navigation between different views within a tab, promoting a clean separation of concerns and state management. The Tab2Page enum is marked as Hashable, which is required for it to work with NavigationPath.

Conclusions

Coordinator is a key component that allows to unload ViewModel or ViewModel logic for controlling navigation logic. I hope this post will help you to understand better this pattern.

You can find the source code used for this post in the repository linked below.

References

Copyright © 2024-2025 JaviOS. All rights reserved