Search code examples
swiftswiftuiswiftui-navigationstack

NavigationStack failing to show the correct view hierarchy after pushing value from other tab


I have a bug in my application so I decided to create a self-contained example for the sake of being simple, which reproduces the problem.

I have a content view with two tabs: contacts and users:

struct ContentView: View {
    @StateObject var navigationState = NavigationState()
    
    var body: some View {
        TabView(selection: $navigationState.tabSelection) {
            ContactsView()
                .tabItem {
                    Image(systemName: "star")
                }
                .tag(NavigationState.TabSelection.contacts)
                .environmentObject(navigationState)
            
            NavigationStack(path: $navigationState.users) {
                UserListView()
            }.tabItem {
                    Image(systemName: "person")
            }
            .tag(NavigationState.TabSelection.users)
            .environmentObject(navigationState)
        }
    }
}

NavigationState holds the state of the selected tab and navigation:

class NavigationState: ObservableObject {
    enum TabSelection {
        case contacts, users
    }
    
    @Published var users: [User] = []
    @Published var tabSelection: TabSelection = .contacts
}

Navigation is only possible in the 'users' tab, while the 'contacts' tab holds a list of usernames. Once the user clicks on a username, it should navigate on the other tab (users) and show that username:

struct ContactsView: View {
    @EnvironmentObject var navigationState: NavigationState
    
    @State var users: [User] = [
        User(username: "A"),
        User(username: "D")
    ]
    
    var body: some View {
        VStack {
            ForEach(users) { user in
                Text(user.username)
                    .onTapGesture {
                        navigationState.tabSelection = .users
                        navigationState.users = [user]
                    }
            }
        }
    }
}

Instead, from the 'users' view, I first display a list of usernames, and then if I click on any of them I should see that username displayed:

struct UserListView: View {
    @EnvironmentObject var navigationState: NavigationState
    
    @State var users = [
        User(username: "A"),
        User(username: "B"),
        User(username: "C")
    ]
    
    var body: some View {
        VStack {
            ForEach(users) { user in
                Text(user.username)
                    .onTapGesture {
                        navigationState.users = [user]
                    }
            }
        }
        .navigationDestination(for: User.self) { user in
            UserView(user: user)
        }
    }
}

The detail view for a user is here:

struct UserView: View {
    @State var user: User
    
    var body: some View {
        VStack {
            Text(user.username)
        }
    }
}

So to summarize, when I navigate to the 'users' tab, this is what I see:

enter image description here

If I click on 'B', a UserView object is pushed in the navigation stack and this is what I see:

enter image description here

So far so good. But what if I want to navigate to the 'users' tab and show another username ('D') in my example from the 'contacts' tab? This is what I see when I open the 'contacts' tab for the first time:

enter image description here

If I click on 'D', this code should be executed:

Text(user.username)
    .onTapGesture {
        navigationState.tabSelection = .users
        navigationState.users = [user]
    }

So I would expect the 'users' tab to be selected, with user 'D' shown. Instead this is what I see:

enter image description here

I still display user 'A', which was my previous selection. This doesn't happen if in the 'users' tab I don't navigate to any user. So for example, if I run these steps:

  1. Launch the app
  2. Navigate to the 'contacts' tab
  3. Press 'D'

In this case I correctly display 'D' in the 'users' tab. The problem occurs only when I had previously navigated to another user from the 'users' tab.

Since my NavigationState object is @StateObject and it's passed down the hierarchy to every subview, I would expect every view to have the correct version of my navigation state. I verified this by printing the variable and indeed it's updated correctly. Does anybody know if there is a solution (which probably involves using different modifiers) to go around this problem?


Solution

  • This seems to be a side effect of a formerly initialized @State var that has not been declared private (which one should always do :))

    In UserView replace

        @State var user: User
    

    with

        let user: User
    

    If I do that it runs fine for me.