SwiftUI NavigationLink behaviour is different when selection is set directly, rather than from viewModel

Viewed 691

I've been working with SwiftUI and ran into unexpected behavior.

I have View A and View B and View C. View C has EnviromentObject that changes AppState from View A

View B has ViewModel with selection

If I call function from ViewModel to change the selection then View C is shown for a few seconds and then it automatically pops back to View B

If I change selection directly from View B (not from ViewModel), everything works as expected. Also, if I comment out onDissapear, it also works. But, I need to change environmentObject when screen dissapeared Here is View B and ViewModel

import SwiftUI

class AppState: ObservableObject {
    @Published
    var shouldHideUserInfo = false
}


struct ContentView: View {
    
    @EnvironmentObject
    var appState: AppState
    
    @State
    var selection: Int? = nil
    
    var body: some View {
        NavigationView {
            VStack {
                if !appState.shouldHideUserInfo {
                    Text("USER INFO")
                }
                
                NavigationLink(
                    destination: ViewA(),
                    tag: 1,
                    selection: $selection,
                    label: { EmptyView()})
                
                Button("MOVE TO VIEW A") {
                    selection = 1
                }
            }
        }
    }
}


class ViewAModel: ObservableObject {
    @Published
    var selection: Int? = nil
    
    func navigate() {
        selection = 2 //<- this doesnt
    }
}

struct ViewA: View {
    
    @ObservedObject
    var viewModel: ViewAModel
    
    init() {
        viewModel = ViewAModel()
    }

    @State
    var selection: Int? = nil //<- this works
    
    var body: some View {
        VStack
        {
            Text("VIEW A")
            
            NavigationLink(
                destination: ViewB(),
                tag: 2,
                selection: $viewModel.selection,
                label: { EmptyView()})
            
            Button("MOVE TO VIEW B") {
                //selection = 2 <-- this works
                viewModel.navigate() //<- this doesnt
            }
           
        }
    }
}

struct ViewB: View {
    
    @EnvironmentObject
    var appState: AppState

    @State
    var selection: Int? = nil
    
    var body: some View {
        VStack
        {
            Text("VIEW B")
           
        }
        .onAppear {
            appState.shouldHideUserInfo = true
        }
    }
}

Factory pattern didn't solve the issue:

    static func makeViewA(param: Int?) -> some View {
        let viewModel = ViewAModel(param: param)
        return ViewA(viewModel: viewModel)
    }
}
1 Answers

I see... it is a bit different than in post. The issue is because view model is recreated (this is long observed behavior of NavigationView) and thus binding lost.

The quick fix is

struct ViewA: View {
    
    @StateObject
    var viewModel: ViewAModel = ViewAModel()
    
    init() {
//        viewModel = ViewAModel()
    }

    // ... other code
}

alternate is to keep ownership of view model outside of ViewA.

Update: SwiftUI 1.0 compatible - here is variant that works everywhere. The reason of the issue is in AppState. The code in ViewB updates appState

.onAppear {
    appState.shouldHideUserInfo = true
}

that causes rebuild of ContentView body, which recreates ViewA, which recreates NavigationLink, which drops previous link and ViewB got closed.

To prevent this we need to avoid rebuild ViewA. This can be done by making ViewA is-a Equatable, so SwiftUI check if ViewA needs to be recreated and we will answer NO.

Here is how it goes:

NavigationLink(
    destination: ViewA().equatable(),    // << here !!
    tag: 1,
    selection: $selection,
    label: { EmptyView()})

and

struct ViewA: View, Equatable {
    static func == (lhs: ViewA, rhs: ViewA) -> Bool {
        true
    }
    
    // .. other code
Related