NavigationSplitView with Employee and Department models

Apple introduced NavigationStack and NavigationSplitView at WWWDC 2022 to replace NavigationView, which has been deprecated. The documentation for NavigationSplitView includes sample code, but the code does not work as it is missing model data. Here is some model data to allow the sample code in the documentation to work.

NavigationSplitView was introduced at WWDC 2022 for iOS 16.0 and MacOS 13.0. I'm torn on whether or not I should complain about the documentation on how to use this, as the documentation is much more detailed than other documentation on SwiftUI and there is sample code. I love sample code, and in general, I find it way more helpful than paragraphs of text explaining how to use an API. However, I do find it frustrating when the sample code does not work. Perhaps there is sample code somewhere that I missed that includes model data for Employees and Departments, so I created the following

There is other code released this year from Apple on using NavigationSplitView in the Sample project for Navigation Cookbook related to WWDC22 session 10054: The SwiftUI cookbook for navigation. This is great, but the WWDC sessions fade into the background and one may forget where to get the sample projects when looking for information on use of NavigationSplitView.



Two column NavigationSplitView

The first block of sample code in NavigationSplitView is to present a two-column navigation split view. This code on its own produces three compile errors - no definition for Employee, model or EmployeeDetails

 1struct ContentView: View {
 2    @State private var employeeIds: Set<Employee.ID> = []
 3    
 4    var body: some View {
 5        NavigationSplitView {
 6            List(model.employees, selection: $employeeIds) { employee in
 7                Text(employee.name)
 8            }
 9        } detail: {
10            EmployeeDetails(for: employeeIds)
11        }
12    }
13}

Three errors with sample code for two-column navigation split view
Three errors with sample code for two-column navigation split view



Define Employee, model and EmployeeDetails

A closer look at the errors above indicates that Employee is likely to be a struct that conforms to Identifiable protocol and also has a name property. There could be other properties to the Employee, but we can get the existing code working with just a name. Define the model data to contain a list of employees and to return a list of selected employees. The EmployeeDetails could be a SwiftUI View or a ViewBuilder function as the use of for as the parameter name suggests.

The sample code now compiles and works with these additions.


Employee

1struct Employee: Identifiable {
2    var id = UUID()
3    var name: String
4}

Model for Two Columns

 1struct ModelTwoColumn {
 2    var employees: [Employee]
 3
 4    init() {
 5        employees = [
 6            Employee(name: "Iron Man"),
 7            Employee(name: "Hulk"),
 8            Employee(name: "Wanda"),
 9            Employee(name: "Groot"),
10            Employee(name: "Black Widow")
11        ]
12    }
13    
14    func selectedEmployees(selectedIds: Set<Employee.ID>) -> [Employee] {
15        employees.filter { selectedIds.contains($0.id) }
16    }
17}

EmployeeDetails

 1fileprivate var model = ModelTwoColumn()
 2
 3@ViewBuilder
 4fileprivate func EmployeeDetails(for employeeIds: Set<Employee.ID>) -> some View {
 5    VStack {
 6        ForEach (model.selectedEmployees(selectedIds: employeeIds) ) {
 7            Text("\($0.name)")
 8                .font(.system(size: 100, design:.rounded))
 9        }
10    }
11}

Defining Employee data allows display of NavigationSplitView for two-column navigation with multi-select
Defining Employee data allows display of NavigationSplitView for two-column navigation with multi-select



Three column NavigationSplitView

The sample code for three column NavigationSplitView fails to compile with a number of errors. These all seem to be caused by the lack of a definition for Department.

 1struct ThreeColumnView: View {
 2    @State private var departmentId: Department.ID? // Single selection.
 3    @State private var employeeIds: Set<Employee.ID> = [] // Multiple selection.
 4    
 5    var body: some View {
 6        NavigationSplitView {
 7            List(model.departments, selection: $departmentId) { department in
 8                Text(department.name)
 9            }
10        } content: {
11            if let department = model.department(id: departmentId) {
12                List(department.employees, selection: $employeeIds) { employee in
13                    Text(employee.name)
14                }
15            } else {
16                Text("Select a department")
17            }
18        } detail: {
19            EmployeeDetails(for: employeeIds)
20        }
21    }
22}

Errors with sample code for three-column navigation split view
Errors with sample code for three-column navigation split view



Define Department struct

The Department struct is also identifiable and has at least a name property and a list of Employee's in the department. The model for the three column sample code consists of a list of Departments, each with a list of Employees. The selectedEmployees function in the model returns a list of Employees that match the list of Employee IDs.


Department

 1struct Department: Identifiable {
 2    var id = UUID()
 3    var name: String
 4    var employees: [Employee]
 5    
 6    init(name: String, employees: [Employee]) {
 7        self.name = name
 8        self.employees = employees
 9    }
10}

Model for Three Columns

 1struct ModelThreeColumn {
 2    var departments: [Department]
 3
 4    init() {
 5        departments = [
 6            Department(name: "Earth", employees: [
 7                Employee(name: "Hulk"),
 8                Employee(name: "Iron Man"),
 9                Employee(name: "Wanda")
10            ]),
11            Department(name: "Galaxy", employees: [
12                Employee(name: "Drax"),
13                Employee(name: "Gamora"),
14                Employee(name: "Groot"),
15                Employee(name: "Star-lord")
16            ]),
17            Department(name: "Asgard", employees: [
18                Employee(name: "Hela"),
19                Employee(name: "Loki"),
20                Employee(name: "Thor")
21            ]),
22        ]
23    }
24    
25    func department(id: Department.ID?) -> Department? {
26        self.departments.first(where: { $0.id == id })
27    }
28    
29    func selectedEmployees(selectedIds: Set<Employee.ID>) -> [Employee] {
30        return self.departments.flatMap {
31            $0.employees.filter { selectedIds.contains($0.id) }
32        }
33    }
34}

EmployeeDetails using the model for Three Columns

 1fileprivate var model = ModelThreeColumn()
 2
 3@ViewBuilder
 4fileprivate func EmployeeDetails(for employeeIds: Set<Employee.ID>) -> some View {
 5    VStack {
 6        ForEach (model.selectedEmployees(selectedIds: employeeIds) ) {
 7            Text("\($0.name)")
 8                .font(.system(size: 100, design:.rounded))
 9        }
10    }
11}

Defining Department data allows display of NavigationSplitView for three-column navigation with multi-select
Defining Department data allows display of NavigationSplitView for three-column navigation with multi-select



Three column NavigationSplitView with columnVisibility

There is an extra snippet of code to set the initial state of the column visibility using NavigationSplitViewVisibility. This is set to all to show all the columns on initial view.

 1struct ThreeColumnVisibilityView: View {
 2    @State private var departmentId: Department.ID? // Single selection.
 3    @State private var employeeIds: Set<Employee.ID> = [] // Multiple selection.
 4    @State private var columnVisibility = NavigationSplitViewVisibility.all
 5    
 6    var body: some View {
 7        NavigationSplitView(columnVisibility: $columnVisibility) {
 8            List(model.departments, selection: $departmentId) { department in
 9                Text(department.name)
10            }
11        } content: {
12            if let department = model.department(id: departmentId) {
13                List(department.employees, selection: $employeeIds) { employee in
14                    Text(employee.name)
15                }
16            } else {
17                Text("Select a department")
18            }
19        } detail: {
20             EmployeeDetails(for: employeeIds)
21        }
22    }
23}

Three column NavigationSplitView with initial columnVisibility set to all
Three column NavigationSplitView with initial columnVisibility set to all



Sample code selects from multiple Departments

The sample code provided for the three-column view has a State property for a set of Employee IDs. This Set is bound to a List of Employees selection. The default behavior is to add the selected employee IDs to the set when the Employee is selected. When a second Department is selected in the three-column view, the set of employees is not cleared. This results in the behavior of selecting Employees in the second and further Departments adding to the set of selected Employee IDs. This feels incorrect to me, as I expect to only see the details of the selected Employees within the currently selected Department.

One way to fix this is to modify the sample code to pass the selected Department ID as well as the Employee IDs to the View Details.

 1extension ModelThreeColumn {
 2    func selectedEmployees(departmentId: Department.ID?,
 3                           employeeIds: Set<Employee.ID>) -> [Employee] {
 4        var matchedEmployees: [Employee] = []
 5        if let dept = self.departments.first(where: { $0.id == departmentId }) {
 6            matchedEmployees = dept.employees.filter { employeeIds.contains($0.id) }
 7        }
 8        return matchedEmployees
 9    }
10}
 1@ViewBuilder
 2fileprivate func EmployeeDetails(by departmentId: Department.ID?,
 3                                 for employeeIds: Set<Employee.ID>) -> some View {
 4    VStack {
 5        ForEach (model.selectedEmployees(departmentId: departmentId,
 6                                         employeeIds: employeeIds) ) {
 7            Text("\($0.name)")
 8                .font(.system(size: 100, design:.rounded))
 9        }
10    }
11}
 1struct ThreeColumnDepartmentView: View {
 2    @State private var departmentId: Department.ID? // Single selection.
 3    @State private var employeeIds: Set<Employee.ID> = [] // Multiple selection.
 4    
 5    var body: some View {
 6        NavigationSplitView() {
 7            List(model.departments, selection: $departmentId) { department in
 8                Text(department.name)
 9            }
10        } content: {
11            if let department = model.department(id: departmentId) {
12                List(department.employees, selection: $employeeIds) { employee in
13                    Text(employee.name)
14                }
15            } else {
16                Text("Select a department")
17            }
18        } detail: {
19            EmployeeDetails(by: departmentId, for: employeeIds)
20        }
21    }
22}

Three column NavigationSplitView selects across multiple departments
Three column NavigationSplitView selects across multiple departments


Three column NavigationSplitView with Detail view using Department and Employee
Three column NavigationSplitView with Detail view using Department and Employee



Select a single Employee

Another way to fix the issue of selecting Employees from multiple Departments is to modify the sample code to only select a single Employee at a time. This also requires modifying the View Details to display details for only a single Employee. Single select may be a more common way of using the three-column view of NavigationSplitView.

 1extension ModelThreeColumn {
 2    func selectedEmployee(selectedId: Employee.ID?) -> Employee? {
 3        for dept in self.departments {
 4            if let emp = dept.employees.first(where: { $0.id == selectedId }) {
 5                return emp
 6            }
 7        }
 8        return nil
 9    }
10}
 1@ViewBuilder
 2fileprivate func EmployeeDetails(for employeeId: Employee.ID?) -> some View {
 3    VStack {
 4        if let employee = model.selectedEmployee(selectedId: employeeId) {
 5            Text("\(employee.name)")
 6                .font(.system(size: 100, design:.rounded))
 7        } else {
 8            Text("Select Employee")
 9        }
10    }
11}
 1struct ThreeColumnSingleView: View {
 2    @State private var departmentId: Department.ID? // Single selection.
 3    @State private var employeeId: Employee.ID? // Single Employee selection.
 4    
 5    var body: some View {
 6        NavigationSplitView() {
 7            List(model.departments, selection: $departmentId) { department in
 8                Text(department.name)
 9            }
10        } content: {
11            if let department = model.department(id: departmentId) {
12                List(department.employees, selection: $employeeId) { employee in
13                    Text(employee.name)
14                }
15            } else {
16                Text("Select a department")
17            }
18        } detail: {
19             EmployeeDetails(for: employeeId)
20        }
21    }
22}

Three column NavigationSplitView single select
Three column NavigationSplitView single select




Conclusion

It is possible that the motivation behind having example code (that does not work) in the SwiftUI documentation for NavigationSplitView is to force developers to work a little harder to get the code to work and help us learn. It is true that it is easier to forget code that is copied and pasted rather than dig in and understand the code. I still find it frustrating, but I have learned more about NavigationSplitView by reverse engineering these samples to create models to get the sample code to work. Apple do provide working code in Sample project for Navigation Cookbook.

The code for EmployeeNavigationSplitViewApp is available on GitHub - but you won't need it as I'm sure you would rather figure it out for yourself. 😀