E2506
Dev_Log

01. A Navigation Pattern for macOS, iPadOS, and iOS in SwiftUI

It’s been a while since SynapNet was updated to use NavigationSplitView and NavigationStack in SwiftUI. Since it's one codebase for all platforms, I took some time to explore the best way to achieve the desired navigation patterns, and I think the solution is worth documenting.

The goal is to build a sidebar with fixed items and a dynamic user list, along with a navigation stack to show details of the selected item. On compact devices like iOS, the detail view should appear first, similar to the old navigation behavior.

In the following sections, I will walk through the code and provide a detailed explanation of the solution.

The code is available in this repository

The RouterModel manages the navigation state for each scene, allowing programmatic navigation through state updates. Since the app may support multiple windows, I chose not to use a singleton. The UserModel provides the data model for this demo. The horizontal size class is passed to the RouterModel to adjust navigation behavior on compact views, such as on iOS devices.

1import SwiftUI
2
3struct ContentView: View {
4  @Environment(.horizontalSizeClass) var horizontalSize: UserInterfaceSizeClass?
5  @StateObject private var routerModel = RouterModel()
6  @State private var userModel = UserModel.shared
7  

A NavPath enum is introduced to represent the navigation path. It conforms to Hashable, allowing it to be used as the value for navigation bindings.

1//RouterModel.swift
2enum NavPath:Hashable {
3  case home, user(Int), profile(Int), settings
4}

The sidebar uses a List that includes some fixed items and a dynamic section for users. Tapping a NavigationLink updates the selection binding value.

8  var body: some View {
9    NavigationSplitView {
10      // Sidebar column
11      List(selection: $routerModel.currentPath) {
12        NavigationLink(value: NavPath.home) {
13          Label("Home", systemImage: "house")
14        }
15        Section("User") {
16          ForEach(userModel.users) { user in
17            NavigationLink(value: NavPath.user(user.id)) {
18              Label("User (user.id)", systemImage: "person")
19            }
20          }
21        }
22        Section {
23          NavigationLink(value: NavPath.settings) {
24            Label("Settings", systemImage: "gear")
25          }
26        }
27      }
28      .navigationTitle("Sidebar")

A toolbar is used to add a button for creating a new user, available across all platforms. On macOS, the minimum width of the sidebar is set to ensure the toolbar button remains visible.

29      #if os(macOS)
30        .frame(minWidth: 200)
31      #endif
32      .toolbar {
33        ToolbarItem(placement: .confirmationAction) {
34          Button {
35            userModel.addUser()
36          } label: {
37            Label("Add User", systemImage: "plus")
38          }
39        }
40      }

The detail column contains a NavigationStack to display the detail view of the selected user or item. The DetailView renders content based on the provided path. Line 46 displays the item selected in the sidebar, while line 49 renders the view within the NavigationStack. The onChange modifier is used to pass the horizontal size class to the RouterModel. In older SwiftUI versions, you may also need to use the onAppear modifier to set the initial horizontal size class value.

41    } detail: {
42      // Detail column with NavigationStack
43      NavigationStack(path: $routerModel.paths) {
44        DetailView(item: routerModel.currentPath ?? .home)
45          .environmentObject(routerModel)
46          .navigationDestination(for: NavPath.self) { path in
47            DetailView(item: path)
48              .environmentObject(routerModel)
49          }
50      }
51    }.onChange(of: horizontalSize, initial: true) {
52      routerModel.horizontalSize = horizontalSize
53    }
54  }
55}

At this point, there are two variables to manage: currentPath and paths. The currentPath controls the sidebar selection, while the paths manages the detail view’s NavigationStack. Setting the default value of currentPath to .home ensures the sidebar initially highlights the home item and displays the home view by default on compact views.

1// RouterModel.swift
2@MainActor
3final
4  class RouterModel: ObservableObject
5{
6  var horizontalSize: UserInterfaceSizeClass? = nil
7  @Published var currentPath: NavPath? = .home
8  @Published var paths: [NavPath] = []
9

When viewing a user’s ProfileView, deleting the user removes them from the sidebar. As a result, the current navigation stack should be reset, and the app should automatically navigate back to the nearest selectable item in the sidebar.

1struct ProfileView: View {
2  @EnvironmentObject var routerModel: RouterModel
3  let userID: Int
4  var body: some View {
5    VStack {
6      Text("Profile View for User (userID)")
7      Button(role: .destructive) {
8        UserModel.shared.removeUser(userID)
9        routerModel.backToRoot()
10      } label: {
11        Label("Delete User (userID)", systemImage: "trash")
12      }
13    }
14  }
15}

This is how you can change the navigation path programmatically. If in a compact view, it will navigate back to the sidebar. If on a regular view, it will navigate to the nearest root view (the user view in this case). If no users are available, it will navigate back to the home view.

9// RouterModel.swift
10  func backToRoot() {
11    // Back to silderbar on compact size like iPhone
12    if horizontalSize == .compact {
13      currentPath = nil
14      return
15    }
16
17    // Back to next root view (user view in this case)
18    if case .user(_) = currentPath, let user = UserModel.shared.users.first {
19      currentPath = .user(user.id)
20    } else {
21      paths = []
22      currentPath = .home
23    }
24  }
25}

This is how you can implement a navigation pattern that works across macOS, iPadOS, and iOS using SwiftUI. And have the full control of the navigation states.

Last Update: Jun.2025