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.
iFrame
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
that displays the detail view of the selected user or item. The DetailView
renders content based on the provided navigation path. Line 44 shows the item selected in the sidebar, while line 47 displays the corresponding view within the NavigationStack
. The onChange
modifier is used to pass the horizontal size class to the RouterModel
. In older versions of SwiftUI, you may also need to use the onAppear
modifier to pass 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 paths
manages the navigation stack in the detail view. Setting the default value of currentPath
to .home
ensures the sidebar initially highlights the home item and that the home view is displayed by default on compact devices.
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
Programmatic Routing
When viewing a user’s ProfileView
, deleting the user will remove them from the sidebar. When this happens, the current navigation stack should be reset, and the app should automatically navigate 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 update the navigation path programmatically. In a compact view, it will navigate back to the sidebar. In a regular view, it will navigate to the nearest root view (the user view in this case). If no users are available, it will fall 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 I implemented a navigation pattern that works seamlessly across macOS, iPadOS, and iOS using SwiftUI, while giving me full control over the navigation states.
I hope this is helpful and inspiring for your own projects. Feel free to reach out if you have any questions or suggestions!
Last Update: Jul.2025