SwiftUI : NavigationStack in new iOS 18 TabView pushes twice when path in parameter

Hello,

With iOS 18, when NavigationStack is in new TabView, with path parameter containing current navigation state is set, the navigation destination view is pushed twice.

See below with example that pushes twice on iOS 18 but is correct on iOS 17

@MainActor
class NavigationModel: ObservableObject {
    static let shared = NavigationModel()
    
    @Published var selectedTab: String
    @Published var homePath: [Route]
    @Published var testPath: [Route]
}


struct ContentView: View {
    @StateObject private var navigationModel: NavigationModel = NavigationModel.shared
    
    var body: some View {
        TabView(selection: $navigationModel.selectedTab){
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house")
                }
                .tag("home")
            
            TestView()
                .tabItem {
                    Label("Test", systemImage: "circle")
                }
                .tag("test")
        }
    }
}


struct HomeView: View {
    @StateObject private var navigationModel: NavigationModel = NavigationModel.shared

    var body: some View {
        NavigationStack(path: $navigationModel.homePath){
            VStack{
                Text("home")
                NavigationLink(value: Route.test1("test1")){
                    Text("Go to test1")
                }
            }
            .navigationDestination(for: Route.self){ route in
                NavigationModelBuilder.findFinalDestination(route:route)
            }
        }
    }
}

I don't what causes the issue because it works well on iOS 16 and iOS 17. I think the path is somehow reset but I don't why (maybe by the TabView ?)

Note that the bug only occurs with TabView. Don't really know if it is a TabView bug or if it is on my side.

I filed a feedback with sample project FB14312064

Answered by brebispanique in 799115022

The issue seems to be fixed in iOS 18 beta 5

I am seeing the same thing. App that works fine on 17.5 is quite broken on 18. I also notice that after navigating back and forth a few times the whole navigation stack seems corrupted. Old views that you have previously popped re-appear in the wrong context in the navigation stack, and views are not completely drawn

Thanks for submitting a bug report @brebispanique.

While the team looks into the Feedback report, Trying making these changes to your sample project and let me know if you're still able to reproduce the issue:

  • You could also use the Route enum instance itself for , since it's hashable and get rid of the custom implementation.
enum Route: Hashable {
    case test1(String)
    case test2(String)
    case empty(String)
    
    var id: Self {
        self
    }
}
  • HomeView and TestView should use ObservedObject property wrapper since you are passing a StateObject into a subview.
struct TestView: View {
    @ObservedObject var navigationModel: NavigationModel
    ......

}


struct HomeView: View {
    @ObservedObject var navigationModel: NavigationModel
.....
    }

I'll also suggest you consider migrating from the Observable Object protocol to the Observable macro as well.

I have found a workaround using NavigationPath instead of an array of Route in my NavigationModel

class NavigationModel: ObservableObject {
    static let shared = NavigationModel()
    
    @Published var selectedTab: String
    @Published var homePath: NavigationPath
    @Published var testPath: NavigationPath
}

Edit: It fixes the "push twice" issue but NavigationStack still acts weirdly, even though the path is correct, some views are not correctly popped.

Removing the TabView still fixes all issues so I think the TabView is buggy.

I can confirm that everything works correct when using the NavigationStack outside a TabView. Inside a TabView, even if there is only a single tab, exhibits incorrect behaviour

I'm also facing this issue. I didn't know the TabView was what was causing it. Thought I was going crazy. @DTS Engineer When do you think this could be fixed?

I also face similar issue and wrote about it in another post: https://developer.apple.com/forums/thread/760041

I can confirm that the usage NavigationPath fixes this issue but the other issues appear. A views that weren't pushed appear in path and I have to pop the several times. So it seems that the combination TabView + NavigationStack inside is buggy.

Two notes:

  1. I tried to use iOS 17+ @Observable approach. It didn’t help.
  2. Using @State var path: [RouterDestination] = [] directly inside View seems to help. But it is not what I want as I need this property to be @Published and located inside custom Router class where I can get an access to it, and use for programmatic navigation if needed.

FYI I am also able to reproduce the issue with latest iOS 18 beta 4.

I can confirm that the issue IS NOT fixed in iOS 18 beta 4 Simulator (22A5316j).

Accepted Answer

The issue seems to be fixed in iOS 18 beta 5

Only tried on my iPad as yet, and it does seem to be fixed in beta 5

@brebispanique @sjb_s it is not fixed on my side, unfortunately. I can see the same issue on Simulator.

Do you test on a real device or simulator?

Could you please attach a full code of your solution so I'll be able to compile it and check?

Not fixed on my side either. My path is a @Published variable in an ObservableObject. Used to work fine in iOS 17

This is a content view for a test app that had the issue pre-beta 5, but works fine on my iPad with beta 5

import SwiftUI

class Item: Identifiable, Hashable {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    static func == (lhs: Item, rhs: Item) -> Bool {
        lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

class Model: ObservableObject {
    @Published var path = [Item]()
}

struct ContentView: View {
    var items = [
        Item(name: "Item 1"),
        Item(name: "Item 2"),
        Item(name: "Item 3"),
    ]
    
    @StateObject private var model = Model()
    
    var body: some View {
        TabView {
            Text("Go to Tab 2")
                .tabItem { Label("Tab 1", systemImage: "storefront") }
                .tag("1")
            tab2()
                .tabItem { Label("Tab 2", systemImage: "globe") }
                .tag("2")
        }
    }
    
    @ViewBuilder
    func tab2() -> some View {
        NavigationStack(path: $model.path) {
            List(items) { item in
                NavigationLink(item.name, value: item)
            }
            .navigationDestination(for: Item.self) { item in
                DetailView(item: item)
            }
        }
    }
}

struct DetailView: View {
    var item: Item
    
    var body: some View {
        Text("Details...\(item.name)")
    }
}

#Preview {
    ContentView()
}

@sjb_s @dderg @brebispanique @DTS Engineer Here is the full code (a modification of @sjb_s 's example) that still DOESN'T work well.

The main reason is that navigationDestination is located in View extension. It is how it is designed in my real app where I have a single navigationDestination for the whole app and all possible routes inside it.

Everything works well in iOS 17.x with this approach, but still doesn't work well in iOS 18.0 Beta 5.

Any ideas?

import SwiftUI

class Item: Identifiable, Hashable {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    static func == (lhs: Item, rhs: Item) -> Bool {
        lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

class Model: ObservableObject {
    @Published var path = [Item]()
}

struct ContentView: View {
    var items = [
        Item(name: "Item 1"),
        Item(name: "Item 2"),
        Item(name: "Item 3"),
    ]
    
    @StateObject private var model = Model()
    
    var body: some View {
        TabView {
            Text("Go to Tab 2")
                .tabItem { Label("Tab 1", systemImage: "storefront") }
                .tag("1")
            tab2()
                .tabItem { Label("Tab 2", systemImage: "globe") }
                .tag("2")
        }
    }
    
    @ViewBuilder
    func tab2() -> some View {
        NavigationStack(path: $model.path) {
            List(items) { item in
                NavigationLink(item.name, value: item)
            }
            .withAppRouter()
        }
    }
}

extension View {
    func withAppRouter() -> some View {
        navigationDestination(for: Item.self) { item in
            DetailView(item: item)
        }
    }
}

struct DetailView: View {
    var item: Item
    
    var body: some View {
        Text("Details...\(item.name)")
    }
}

@DTS Engineer I created and send Bug report in Feedback assistant: https://feedbackassistant.apple.com/feedback/14743917

@ulian_onua I spoke too soon, and the issue does still occur, just much less frequently than before. While most of the time I no longer see the double-push of views, in some situations the exact same code will do a double-push. I don't know what makes those situations different than the situations where it doesn't happen.

So, in short, the issue is NOT fixed in iOS 18.1 beta 2

@sjb_s thank you for your feedback.

You can see my code in the post above that contains the code that always fails (double-push always happens) and some explanation.

Also I created the Bug report with the full descriptions and steps how to reproduce this bug: https://feedbackassistant.apple.com/feedback/14743917

@DTS Engineer we really your help here please.

I was using a NavigationPath (this is in my real app, not in my test code), and found that removing that fixed all my issues. My use is pretty simple and I don't need to use a path with the NavigationStack

The issue IS NOT fixed in Xcode 16 Beta 6 + iOS 18 Beta 7.

This issue still persists in the latest 18.0 beta.

I am experiencing this issue, although only when NavigationLinks at tapped after a list has been filtered using .searchable. That is, rows containing a NavigationLink that normally will only lead to one push on the stack will modify the path twice when that row is tapped after filtering the list with a search term using a .searchable search box.

This remains present in both iOS 18 beta 7 and in iOS 18.1 beta 2.

Still present in iOS 18 beta 8.

In my case, a successful workaround has been moving my convenience modifier around navigationDestination to be used directly within the NavigationStack.

Broken example:

NavigationStack(path: $navModel.myFarmTabPath) {
                MyRootScreen()
                    .appNavigationDestinations() // <- My wrapper around destination selection
            }

Working example:

NavigationStack(path: $navModel.myFarmTabPath) {
                MyRootScreen()
                    .navigationDestination(for: Screen.self) { screen in
                       return screen.representedDestination
                    }
            }

To keep things tidy and allow for easier re-use in my tab views, I've added an extension to Screen which returns a destination:


extension Screen {
    @ViewBuilder
    var representedDestination: some View {
        switch self {
        case .screenOne:
            ScreenOne()
        case .screenTwo:
            ScreenTwo()
        }
    }

I still had the issue in iOS 18 beta 8. In case someone is using several navigationDestinations on a view. In my case it helped to change the order of these. Not sure why but it worked.

I am using only one navigationDestination, but it helped to move the modifier to another view. thanks NickYaw.

SwiftUI : NavigationStack in new iOS 18 TabView pushes twice when path in parameter
 
 
Q