Marco Scabbiolo
Marco Scabbiolo

Reputation: 7459

NavigationStack inside TabView inside NavigationStack throws

I'm trying to migrate a coordinator pattern using UINavigationController into the new NavigationStack.

The navigation flow is quite complex, but I've made a simple project to simplify it to this:

NavigationStack
  -> MainScreen
    -> TabView
      -> FirstTab
        -> NavigationStack
          -> FirstTabFirst
          -> FirstTabSecond
      -> SecondTab
        -> SecondTabScreen
  -> Second Top Screen

Althoug the first Screens in each NavigatorStack are actually the root view of the navigator, it shouldn't be a problem as I can't even get it to navigate to something.

All the navigators have their state defined by a @StateObject to allow for both programatic imperative navigation and NavigationLink, similar to what a Coordinator pattern provides.

Upon launching the app, it immediatelly throws in the @main line, with no further information about the call stack:

Thread 1: Fatal error: 'try!' expression unexpectedly raised an error: SwiftUI.AnyNavigationPath.Error.comparisonTypeMismatch

This is the code for the whole app:

import SwiftUI

protocol NavigationRoute: Hashable {
    associatedtype V: View
    
    @ViewBuilder
    func view() -> V
}

enum Top: NavigationRoute {
    case first, second
    
    @ViewBuilder
    func view() -> some View {
        switch self {
        case .first: TopFirst()
        case .second: TopSecond()
        }
    }
}

enum Tab: NavigationRoute {
    case first, second
    
    @ViewBuilder
    func view() -> some View {
        switch self {
        case .first: TabFirst()
        case .second: TabSecond()
        }
    }
    
    var label: String {
        switch self {
        case .first: return "First"
        case .second: return "Second"
        }
    }
    
    var systemImage: String {
        switch self {
        case .first: return "house"
        case .second: return "person.fill"
        }
    }
    
    @ViewBuilder
    func tab() -> some View {
        view()
            .tabItem { Label(label, systemImage: systemImage) }
            .tag(self)
    }
}

enum FirstTab: NavigationRoute {
    case first, second
    
    @ViewBuilder
    func view() -> some View {
        switch self {
        case .first: FirstTabFirst()
        case .second: FirstTabSecond()
        }
    }
}

class StackNavigator<Route: NavigationRoute>: ObservableObject {
    @Published var routes: [Route] = []
}

class TabNavigator<Route: NavigationRoute>: ObservableObject {
    @Published public var tab: Route
    
    public init(initial: Route) {
        tab = initial
    }
    
    public func navigate(_ route: Route) {
        tab = route
    }
}

@main
struct NavigationStackTestApp: App {
    @StateObject var navigator = StackNavigator<Top>()

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $navigator.routes) {
                TopFirst().navigationDestination(for: Top.self) {
                    $0.view()
                }
            }
        }
    }
}

struct TopFirst: View {
    @StateObject var navigator = TabNavigator<Tab>(initial: .first)

    var body: some View {
        TabView(selection: $navigator.tab) {
            Tab.first.tab()
            Tab.second.tab()
        }
    }
}

struct TopSecond: View {
    var body: some View {
        Text("Top Second")
    }
}

struct TabFirst: View {
    @StateObject var navigator = StackNavigator<FirstTab>()
    
    var body: some View {
        NavigationStack(path: $navigator.routes) {
            FirstTabFirst().navigationDestination(for: FirstTab.self) {
                $0.view()
            }
        }
    }
}

struct TabSecond: View {
    var body: some View {
        Text("Tab Second")
    }
}

struct FirstTabFirst: View {
    var body: some View {
        Text("First Tab First")
    }
}

struct FirstTabSecond: View {
    var body: some View {
        Text("First Tab Second")
    }
}

To avoid it from crashing I have to replace the NavigationStack of the first tab with an EmptyView(), that is changing:

case .first: TabFirst()

to

case .first: EmptyView()

But of course after doing that the first tab is missing and the app doesn't do what it's supposed to.

Has anyone encountered something like this or has an idea of how to get this working? SwiftUI is closed source and it's documentation is very limited so it's really hard to know what's really going on under the hood.

EDIT:

Using NavigationPath as state for StackNavigator doesn't crash the app, but any navigation in the first tab's NavigationStack pushes a new view in the top navigator and hides the bottom tab bar.

I'm assuming you just can't have two navigation stacks in the same hierarchy and expect it to work as you would assume.

Upvotes: 1

Views: 953

Answers (1)

Marco Scabbiolo
Marco Scabbiolo

Reputation: 7459

Using two NavigationStacks in the same hierarchy doesn't work very well as their internal states collide, I would not recommend it and it seems to cause the error. The error is thrown when the lower navigator is declared because it's declared type is also handled by the top navigator, which doesn't have a handler declared for the lower navigator's tap in its navigationDestination.

It is better to use conditional rendering and manual animation/transitions in the upper level and then from there render only one NavigationStack per hierarchy. There is no issue using NavigationStack inside TabView as long as that TabView is not itself inside a NavigationStack.

NavigationPath can be used to support multiple unrelated types in the state stack.

Upvotes: 4

Related Questions