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