Building a Flexible Navigation System in Swift: From UINavigationController to NavigationStack(part 1)

mohamed ahmed
5 min readJul 20, 2024

--

NavigationStack or UINavigationController: A Common Question

The simplest answer is to check if your application supports iOS versions lower than 16. That’s all

That’s all. 🤔

Why Use NavigationStack?

NavigationStack is powerful and can handle all navigation scenarios. However, you might wonder how to create a navigation flow that can be easily migrated from UINavigationController to NavigationStack.

In this example, I’ll show you how to build a structured navigation system that can be easily migrated.

First, we need to understand that we will always work with SwiftUI views at the end of the chain. If the view is a UIKit view type (e.g., UIViewController, UITableViewController, etc.), it should always be wrapped in UIViewRepresentable.

Reasons to Use the Coordinator Pattern

Separation of Concerns:

  • By using the Coordinator pattern, view controllers are only responsible for managing their views, not the navigation logic. This leads to cleaner, more maintainable code.

Reusability:

  • Coordinators can handle the navigation logic for different parts of the app, making it easier to reuse this logic across different view controllers.

Improved Testing:

  • Since Coordinators handle the navigation, testing the view controllers becomes simpler as you can test them in isolation without worrying about navigation.

Scalability:

  • As the app grows, the Coordinator pattern helps in managing complex navigation flows more efficiently, avoiding massive view controller classes.

Decoupling Navigation Logic:

  • By decoupling navigation from view controllers, you can change the flow or presentation logic without touching the view controller code, making your app more flexible.

Better Organization:

  • It provides a clear structure and flow to the application, making it easier for new developers to understand the navigation logic.

Reasons to Use Composite Root

Dependency Management:

  • Composite Root helps manage dependencies in a hierarchical manner, making it easier to control and inject dependencies where needed.

Modular Architecture:

  • It supports a modular approach, allowing different parts of the application to be developed, tested, and maintained independently.

Improved Testability:

  • By organizing dependencies in a hierarchical manner, it becomes easier to mock and test components in isolation.

Single Responsibility Principle:

  • Each component or module within the Composite Root pattern has a single responsibility, leading to better code organization and maintainability.

Scalability:

  • The hierarchical structure allows for better scalability as the application grows, with clear boundaries between different parts of the application.

Consistency:

  • It ensures a consistent way of managing dependencies and organizing the application, which helps in maintaining code quality over time.

Creating the Main Navigation Protocol

Let’s start by creating the main navigation protocol, where you can define custom functions to handle uncommon use cases.

public enum PresentationType { 
case push
case sheet(height: [UISheetPresentationController.Detent], interactive: Bool)
}

public protocol NavigationStateProtocol {
func goTo(_ route: Route, presentAs: PresentationType)
func goBack()
func dismissSheet()
}

Creating the MainCoordinator Class

Now, let’s create the MainCoordinator class responsible for all navigation. This class will conform to NavigationStateProtocol.

final class MainCoordinator:  NavigationStateProtocol  {
let router: UINavigationController

init(router: UINavigationController) {
self.router = router
}

public func goTo(_ route:Route, presentAs: presentationType) {
switch presentAs {
case .push:
router.pushViewController(UIHostingController(rootView: route), animated: true)
case let .sheet(height, interactive):
let host = UIHostingController(rootView: route)
host.modalPresentationStyle = .pageSheet
if let sheet = host.sheetPresentationController {
sheet.detents = height
sheet.selectedDetentIdentifier = .medium
sheet.prefersGrabberVisible = false
}
host.isModalInPresentation = interactive
router.present(host, animated: true)
}
}

func goBack() {
router.popViewController(animated: true)
}

func dismissSheet() {
router.dismiss(animated: true)
}
}

In this implementation:

  • The MainCoordinator class conforms to the NavigationStateProtocol.
  • It initializes with a UINavigationController to manage navigation.
  • The goTo(_:presentAs:) method handles navigation based on the specified presentation type:
  • .push for pushing a view controller onto the navigation stack.
  • .sheet(height:interactive:) for presenting a view controller as a sheet.
  • The goBack() method handles popping the current view controller off the navigation stack.
  • The dismissSheet() method handles dismissing a presented sheet.

Creating Our Views: FirstView and SecondView

Let’s start creating our views, FirstView and SecondView. Each of these views or any number of views (or view controllers) should have the following structure. I’ll continue the example with FirstView.

Step 1: Define the Protocol

First, we define a protocol that specifies the navigation scenarios for FirstView.

public protocol FirstViewScreenRouterProtocol {
func toSecondScreen(name: String)
}

Explanation: The FirstViewScreenRouterProtocol should include all the navigation scenarios for the FirstView screen.

Step 2: Implement the Router

Next, we implement the router for the EntryPoint screen.

classFirstScreenRouter: FirstViewScreenRouterProtocol {
let mainNav: NavigationStateProtocol

init(mainNav: NavigationStateProtocol) {
self.mainNav = mainNav
}

public func toSecondScreen(name: String) {
mainNav.goTo(.flow1(.secondScreen(mainNav: mainNav, name: name)), presentAs: .push)
}
}

Explanation:

  • The FirstScreenRouter class conforms to the FirstViewScreenRouterProtocol.
  • It initializes with a NavigationStateProtocol instance to manage navigation.
  • The toSecondScreen(name:) method navigates to the second screen using the goTo(_:presentAs:) method of the NavigationStateProtocol.

Note: - mainNav.goTo(.appScreens(.secondScreen(mainNav: mainNav, name: name)), presentAs: .push) line might look complex, but we’ll explain it in detail later.

Step 3: Define the View Creator Protocol

We need to define a protocol for creating the views. This protocol will specify how to create the FirstView.

public protocol FirstScreenCreatorProtocol {
func createFirstView(router: NavigationStateProtocol) -> FirstView
}

extension FirstScreenCreatorProtocol {
public func createFirstView(router: NavigationStateProtocol) -> FirstView {
FirstView(router: FirstScreenRouter(mainNav: router))
}
}

Explanation:

  • The FirstScreenCreatorProtocol defines a method for creating the FirstView.
  • An extension of FirstScreenCreatorProtocol provides a default implementation of the createFirstView(router:) method. This implementation initializes the FirstView with a router, specifically an instance of FirstScreenRouter.

Understanding the Navigation Line

Now it’s time to jump back to this line:

  • mainNav.goTo(.flow1(.secondScreen(mainNav: mainNav, name: name)), presentAs: .push)

and understand what it does and how to build it. We will discuss it in reverse, starting with:

  • .secondScreen(mainNav: mainNav, name: name)

    Enum for Navigation Routes
  • Define an enum to represent the navigation routes for the flow.
public enum Flow1Route: Hashable {
case firstScreen(mainNav: NavigationStateProtocol)
case secondScreen(mainNav: NavigationStateProtocol, name: String)

public static func == (lhs: Flow1Route, rhs: Flow1Route) -> Bool {
switch (lhs, rhs) {
case (.firstScreen, .firstScreen):
return true
case (.secondScreen, .secondScreen):
return true
default:
return false
}
}

public func hash(into hasher: inout Hasher) {
switch self {
case .firstScreen:
hasher.combine(0)
case let .secondScreen(_, name):
hasher.combine(name)
}
}
}

Implementing View Protocols

Extend Flow1Route to conform to View, FirstScreenCreatorProtocol, and SecondScreenCreatorProtocol.

extension Flow1Route: View, FirstScreenCreatorProtocol, SecondScreenCreatorProtocol {
public var body: some View {
switch self {
case let .firstScreen(mainNav):
createFirstView(router: mainNav)
case let .secondScreen(mainNav, name):
createSecondView(router: mainNav, name: name)
}
}
}

Explanation:

  • Flow1Route is an enum that defines the possible screens for this navigation flow.
  • It conforms to Hashable to support hashing and equality checking.
  • The View conformance allows it to be used directly in SwiftUI view hierarchies.
  • The protocol conformances (FirstScreenCreatorProtocol and SecondScreenCreatorProtocol) provide methods for creating the respective views.

In this setup:

  • The .secondScreen(mainNav:mainNav, name:name) case is a route for navigating to the second screen.
  • The goTo(_:presentAs:) method uses this route to navigate, specifying that the presentation should be a push.

Check part 2 https://medium.com/@comm.eng_mohamed/navigation-coordinator-explanation-part-2-59b305c156ff

--

--

mohamed ahmed

Senior iOS Developer with 9 years if experience in building & Architecting Large scale mobile Applications