Best Practices for Navigation in SwiftUI

Best Practices for Navigation in SwiftUI: Pushing a View onto the NavigationStack

Navigation is a core part of any SwiftUI app. Handling it efficiently improves both the **developer experience** and the **user experience**. In this post, we’ll explore best practices for pushing views onto a NavigationStack.

The Problem with Traditional Navigation

A common way developers push views in SwiftUI is by using NavigationLink like this:

NavigationLink("Go to Account", destination: AccountScreen())

While this works, it has drawbacks:

  • Hardcoded Destinations: Managing multiple navigation paths becomes difficult.
  • No Type Safety: It's easy to introduce inconsistencies across the app.
  • Difficult to Centralize Logic: Navigation is scattered across different views.

A Protocol-Driven Approach to Navigation

Using a protocol for defining routes allows for:

  • Centralized navigation logic
  • Type safety (reducing runtime errors)
  • Easier testing and maintainability

Defining a Routable Protocol

public protocol Routable: Hashable, View, Identifiable {
    var id: Self { get }
    static func destination(for route: Self) -> Body
}

public extension Routable {
    var id: Self { self }

    static func destination(for route: Self) -> Body {
        route.body
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(self)
    }
}

Using Routable with NavigationStack

Step 1: Define Routes

enum SettingsRoute: Routable {
    case account
    case makeSuggestion
    case reportIssue

    var body: some View {
        switch self {
        case .account: AccountScreen()
        case .makeSuggestion: MakeSuggestionScreen()
        case .reportIssue: ReportIssueScreen()
        }
    }
}

Step 2: Manage Navigation with State

@MainActor
struct SettingsScreen {
    @State var routeToPush: SettingsRoute? = nil

    func accountAction() {
        routeToPush = .account
    }
}

Step 3: Implement the View with NavigationStack

extension SettingsScreen: View {
    var body: some View {
        NavigationStack {
            VStack {
                Button(
                    "Go to Account",
                    action: accountAction
                )
            }
            .navigationDestination(
                item: $routeToPush,
                destination: SettingsRoute.destination
            )
        }
    }
}

Why This Approach Works Well

  • Centralized Navigation Logic: All routes are managed in a single enum.
  • Type Safety: The compiler ensures correctness.
  • Improved Readability: No scattered NavigationLink declarations.
  • Better Maintainability: Decouples navigation from UI logic.

Next Up: Presenting Sheets

In the next post, we’ll explore **best practices for presenting modals (sheets) in SwiftUI**, extending our Routable pattern.

Read Part 2: Presenting Sheets →