12

I am trying to integrate NavigationStack in my SwiftUI app.

I have four views: CealUIApp, OnBoardingView, UserTypeView and RegisterView.

I want to navigate from OnBoardingView to UserTypeView when user presses a button in OnBoardingView.

And, navigate from UserTypeView to RegisterView when user presses a button in UserTypeView

Below is my code for CealUIApp

@main
struct CealUIApp: App {
    
    @State private var path = [String]()
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $path){
                OnBoardingView(path: $path)
            }
        }
    }
}

In OnBoardingView

Button {
    path.append("UserTypeView")
}
label: {
    Text("Hello")
}
.navigationDestination(for: String.self) { string in
    UserTypeView(path: $path)
}

In UserTypeView

Button {
    path.append("RegisterView")
}
label: {
    Text("Hello")
}
.navigationDestination(for: String.self) { string in
    RegisterView()
}

When the button on UserTypeView is pressed, it navigates to UserTypeView instead of RegisterView.

Also, the Xcode logs saying

Only root-level navigation destinations are effective for a navigation stack with a homogeneous path.

3 Answers 3

14

You can get rid of Only root-level navigation destinations are effective for a navigation stack with a homogeneous path by changing the path type to NavigationPath.

@State private var path: NavigationPath = .init()

But then you get a message/error that I think explains the issue better A navigationDestination for “Swift.String” was declared earlier on the stack. Only the destination declared closest to the root view of the stack will be used.

Apple has decided that scanning all views that are available is very inefficient so they will use the navigationDestination will take priority.

Just imagine if your OnBoardingView also had an option for "RegisterView"

 .navigationDestination(for: String.self) { string in
        switch string{
        case "UserTypeView":
            UserTypeView(path: $path)
        case "RegisterView":
            Text("fakie register view")
        default:
            Text("No view has been set for \(string)")
        }
        
    }

How would SwiftUI pick the right one?

So how to "fix"? You can try this alternative.

import SwiftUI

@available(iOS 16.0, *)
struct CealUIApp: View {
    @State private var path: NavigationPath = .init()
    var body: some View {
        NavigationStack(path: $path){
            OnBoardingView(path: $path)
                .navigationDestination(for: ViewOptions.self) { option in
                    option.view($path)
                }
        }
    }
    //Create an `enum` so you can define your options
    enum ViewOptions{
        case userTypeView
        case register
        //Assign each case with a `View`
        @ViewBuilder func view(_ path: Binding<NavigationPath>) -> some View{
            switch self{
            case .userTypeView:
                UserTypeView(path: path)
            case .register:
                RegisterView()
            }
        }
    }
}
@available(iOS 16.0, *)
struct OnBoardingView: View {
    @Binding var path: NavigationPath
    var body: some View {
        Button {
            //Append to the path the enum value
            path.append(CealUIApp.ViewOptions.userTypeView)
        } label: {
            Text("Hello")
        }
        
    }
}
@available(iOS 16.0, *)
struct UserTypeView: View {
    @Binding var path: NavigationPath
    var body: some View {
        Button {
            //Append to the path the enum value
            path.append(CealUIApp.ViewOptions.register)
        } label: {
            Text("Hello")
        }
        
    }
}
@available(iOS 16.0, *)
struct RegisterView: View {
    var body: some View {
        Text("Register")
        
    }
}
@available(iOS 16.0, *)
struct CealUIApp_Previews: PreviewProvider {
    static var previews: some View {
        CealUIApp()
    }
}
12
  • 1
    Nice explanation for the choice of the Apple (+1). Is there a way to send values to views on this approach other than updating an ObservableObject before the append? I think this approach kills the custom inits of the Views.
    – kelalaka
    Mar 19, 2023 at 20:54
  • @kelalaka kills the custom init's? You can pass almost anything with the enum Binding is a limitation but it is a navigationDestination limitation not a limitation of this approach. Mar 19, 2023 at 22:51
  • Do you mean with the ViewOptions enum? That will complicate the coding. Yes, I'm talking about navigationDestionation. While keeping the same value, we cannot call a different init. I've tried to use different value on another view, and come across this warning. Well, I think the observalble is better solution, and it is core of single-source - model seperated desing, as far as I can see.
    – kelalaka
    Mar 20, 2023 at 5:55
  • @kelalaka why wouldn’t you be able to a different init? If you are having an issue you can ask a question someone will help you Mar 20, 2023 at 7:31
  • 1
    @JAHelia you’ll find this useful it is a common issue I see freelancing. I don’t recommend using EnvironmentObject if it can’t be injected from the root view. Reference types can be passed around using this method easily. Dec 14, 2023 at 10:20
7

As an alternative to @lorem ipsum's answer, I'd suggest using NavigationLink instead of a Button as that will handle adding the values internal NavigationPath for NavigationStack. I would only add and pass around your own path if you wanted to do navigation programatically (for example after a network request).

First we have an enum to handle the possible routes and creation of their views:

enum Route {
    case register
    case userType
    
    @ViewBuilder
    var view: some View {
        switch self {
        case .register:
            RegisterView()
        case .userType:
            UserTypeView()
        }
    }
}

Then we have the main app:

@main
struct CealUIApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                OnboardingView()
                .navigationDestination(for: Route.self) { route in
                    route.view
                }
            }
        }
    }
}

And finally the implementation of those views with the various NavigationLink in place:

struct OnboardingView: View {
    var body: some View {
        NavigationLink("Hello", value: Route.userType)
    }
}

struct UserTypeView: View {
    var body: some View {
        NavigationLink("Hello", value: Route.register)
    }
}

struct RegisterView: View {
    var body: some View {
        Text("Register View")
    }
}
3
  • I need to pass a value when the NavigationLink is tapped. How can I do that using this strategy?
    – Carl Smith
    Oct 31, 2023 at 0:35
  • You can add a value to the Route enum, so for example you would have case userType(String) so when you have the navigation link it would be NavigationLink("Hello", value: Route.userType("your value here")). And in the view builder in the enum, you can get that value. Oct 31, 2023 at 10:09
  • For the record, people need to understand that (in most cases) not all clickables are Text looking views with a chevron. Stop suggesting NavigationLink ffs.
    – 6rchid
    Apr 14 at 23:48
-1

Following @lorem ipsum example I think you can change this state variable @State private var path: NavigationPath = .init() with an @ObservableObject so you don't need to pass @Bindings on all the views. You just pass it down from the CealUIApp view as an WnvironmentObject


class NavigationStack: ObservableObject {
    @Published var paths: NavigationPath = .init()
}


@main
struct CealUIApp: App {
    
    let navstack = NavigationStack()
    
    var body: some Scene {
        WindowGroup {
                AppEntry()
                    .environmentObject(navstack)
        }
    }
}

extension CealUIApp {
    enum ScreenDestinations {
        case userTypeView
        case registerView
        
        
        //Assign each case with a `View`
        @ViewBuilder func view(_ path: Binding<NavigationPath>) -> some View {
            switch self{
                case .permissions:
                    UserTypeView()
                case .seedPhrase:
                    RegisterView()
            }
        }
    }

}


// An extra view after the AppView

struct AppEntry: View {
    
    @EnvironmentObject var navStack: NavigationStack
    
    var body: some View {
        NavigationStack(path: $navStack.paths) {
            OnBoardingView()
                .navigationDestination(for: CealUIApp.ScreenDestinations.self) {
                    $0.view($navStack.paths)
                }
        }
    }
}

And then the rest remain the same as @lorem ipsum said.

2
  • NavigationStack, which you are redefining, already exists in SwiftUI. Jan 19, 2023 at 15:57
  • Not caring about the basics of software engineering isn't smart. Jan 19, 2023 at 22:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.