Search code examples
swiftuiswiftui-navigationstackswiftui-navigationpath

Enum based alternative to type-erased NavigationPath for NavigationStack


After realizing the lookup and editing limitations of the built-in, type-erased NavigationPath, I'm exploring an enum-based solution. With this approach, I can inspect and edit the path much easier while still combining multiple types.

Besides sharing this solution, I want to know if there are any drawbacks with this approach. Do I lose any functionality that the native NavigationPath offered?

import SwiftUI

struct WithRouteEnumNavigationPath: View {
    @State var navigationPath = [Route]()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            Text("Root")
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .student(let student):
                        StudentView(student: student)
                    case .teacher(let teacher):
                        TeacherView(teacher: teacher)
                    }
                }
        }
        .overlay {
            VStack {
                Text("Add Student")
                    .onTapGesture {
                        navigationPath.append(.student(Student()))
                    }
                Spacer()
                Text("Add Teacher")
                    .onTapGesture {
                        navigationPath.append(.teacher(Teacher()))
                    }
            }
        }
    }
}

struct StudentView: View {
    let student: Student
    var body: some View {
        Text("Student id \(student.id)")
    }
}

struct TeacherView: View {
    let teacher: Teacher
    var body: some View {
        Text("Teacher id \(teacher.id)")
    }
}

struct Student: Hashable {
    let id = UUID()
}

struct Teacher: Hashable {
    let id = UUID()
}

enum Route: Hashable {
    case student(_ student: Student)
    case teacher(_ teacher: Teacher)
}

#Preview {
    WithRouteEnumNavigationPath()
}

This second code snippet shows the same scenario with the built-in NavigationPath:

import SwiftUI

struct WithTypeErasedNavigationPath: View {
    @State var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            Text("Root")
                .navigationDestination(for: Teacher.self) { teacher in
                    TeacherView(teacher: teacher)
                }
                .navigationDestination(for: Student.self) { student in
                    StudentView(student: student)
                }
        }
        .overlay {
            VStack {
                Text("Add Student")
                    .onTapGesture {
                        navigationPath.append(Student())
                    }
                Spacer()
                Text("Add Teacher")
                    .onTapGesture {
                        navigationPath.append(Teacher())
                    }
            }
        }
    }
}

struct StudentView: View {
    let student: Student
    var body: some View {
        Text("Student id \(student.id)")
    }
}

struct TeacherView: View {
    let teacher: Teacher
    var body: some View {
        Text("Teacher id \(teacher.id)")
    }
}

struct Student: Hashable {
    let id = UUID()
}

struct Teacher: Hashable {
    let id = UUID()
}

#Preview {
    WithTypeErasedNavigationPath()
}

Solution

  • Do I lose any functionality that the native NavigationPath offered?

    Yes. By using a homogeneous navigation path, you effectively made your navigation very centralised.

    Suppose when building your app, you found that in addition to teachers and students, you also need to navigate to views representing Schools. If you use a NavigationPath, you can just add a navigationDestination to whichever view that needs to show schools. For example, there might be a SchoolsList view, whose body looks like this:

    List(schools) { school in
        NavigationLink(school.name, value: school)
    }
    .navigationDestination(for: School.self) {
        SchoolView($0)
    }
    

    You only need to change the view that is concerned about Schools (SchoolsList in this case), and the other views don't need to change at all.

    If you use a Route enum instead, you need to add an extra case to the enum, and add a extra switch case in the navigationDestination that is applied to the root of the navigation stack. You effectively lose separation of concern - the root of the navigation stack needs to know about every kind of view that will potentially be navigated to.

    enum Route: Hashable {
        case student(Student)
        case teacher(Teacher)
        case school(School) // <-----
    }
    
    // ...
    
    NavigationStack(path: $navigationPath) {
        Text("Root")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .student(let student):
                    StudentView(student: student)
                case .teacher(let teacher):
                    TeacherView(teacher: teacher)
                case .school(let school): // <-----
                    SchoolView(school)
                }
            }
    }
    
    // SchoolsList will just be a List
    List(schools) { school in
        NavigationLink(school.name, value: Route.school(school))
    }
    

    Consequently this increases coupling of your views. You cannot easily reuse SchoolsList anymore. Because the list rows in SchoolsList are navigation links of type Route, it must be used in a NavigationStack that handles all Routes, instead of just Schools.