How to use the new NavigationStack for navigation in SwiftUI 4+ (iOS 16+)

468 views Asked by At

I've read the documentation on the topic and I believe I have a basic understanding of it. In theory, it sound pretty good, and not too complex. If i have a model class ModelClass and a view to display the details of the model, ModelDetailsView, I can just append the model instance to the NavigationPath and have a .navigationDestination(for: ModelClass.self) modifier ready to receive the data and display the details view.

So the project setup in simplified terms would be like this:

The ContentView:

struct ContentView: View {
    @StateObject private var navigator = Navigator()

    var body: some View {
        WindowGroup {
            NavigationStack(path: $navigator.path) {
                MainMenu()
                    .navigationDestination(for: ModelClass.self) { model in
                        ModelDetailsView(model: model)
                    }
            }
            .environmentObject(navigator)
        }
    }
}

The Navigator class:

class Navigator: ObservableObject {
    @Published var path = NavigationPath()

    func navigate(with model: any Hashable) {
        path.append(model)
    }
}

And we have the navigation in the MainMenu:

struct MainMenu: View {
    @EnvironmentObject private var navigator: Navigator
    let model = ModelClass()

    var body: some View {
        Button("Go to details") {
            navigator.navigate(with: model)
        }
    }
}

I have multiple problems with the above mentioned method.

First, at the navigation part (navigator.navigate(with: model)), I'm not really specifying my intentions in the code. From that single line, it's not clear if I'm trying to navigate to a details view, or to any other view that uses this class instance as a property. And adding comments kind of ruin the cleanliness of the swift code (for me at least).

But whatever, if I had to, I would write comments to clarify that this is supposed to take me to a details view. But this next problem is far worse...

So what if I have multiple views that take this exact class as property? Let's say I have this ModelDetailsView, but I also have a SecondaryDetailsView. These two views take only one argument, and it's a ModelClass for both. Now, how should I write the .navigationDestination modifier, to distinguish between these two? I could do

.navigationDestination(for: ModelClass.self) { model in
    if someCondition {
        ModelDetailsView(model: model)
    } else {
        SecondaryDetailsView(model: model)
    }
}

but than, this someCondition has to be in the ContentView, which is neither practical nor a good practice.

Some place (I couldn't find the source) suggested to use Strings as identifiers when navigating. So the Navigator would have a function

func show<V>(_ viewType: V.Type) where V: View {
    path.append(String(describing: viewType.self))
}

And that could be used like

navigator.show(ModelDetailsView.self)
// or
navigator.show(SecondaryDetailsView.self)

The problem with this one is that you can't pass arguments to the views.

.navigationDestination(for: String.self) { id in
    if id == String(describing: ModelDetailsView.self) {
        ModelDetailsView(model: ) // how do I get the model for the view?
    }
}

So I'm wondering if anyone has a good solution to this, that could be used in even larger projects without getting too ugly.

1

There are 1 answers

1
Sweeper On BEST ANSWER

As Benzy Neez suggested in the comments, it would be suitable to use an enum with associated values as the type of the navigation path. The names of each case can represent the type of view (whether it be details, or secondaryDetails), and the associated value can store the models you want to pass to the view.

enum Destination: Hashable {
    case details(of: ModelClass) // assuming ModelClass is Hashable
    case secondaryDetails(of: ModelClass)
}

The navigate method can then take a Destination:

func navigate(to destination: Destination) {
    path.append(destination)
}

A usage like navigate(to: .details(of: someModel)) would be very readable.

The navigationDestination(for:destination:) modifier can check which case of the enum it is:

.navigationDestination(for: Destination.self) { dest in
    switch dest {
    case let .details(of: model):
        ModelDetailsView(model: model)
    case let .secondaryDetails(of: model):
        SecondaryDetailsView(model: model)
    }
}