2

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)
    }
}
AsMartynas
  • 159
  • 13
  • Works same with Xcode 12.1 / iOS 14.1. Which environment do you use? – Asperi Nov 24 '20 at 05:46
  • @Asperi my environment is the same. One moment I have created a minimum git repo where this issue is reproducible. https://github.com/martynasNarijauskas/swiftui – AsMartynas Nov 24 '20 at 07:58
  • + I updated current code, so it will be enough just copying it and setting env object from scene delegate – AsMartynas Nov 24 '20 at 08:04

1 Answers1

2

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
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Could you please be more specific how can I keep the ownership of ViewModel outside of ViewA? I tried to create static func with factory pattern to create ViewA with already injected ViewModel, however it didn't solve the issue. I update my question with the code – AsMartynas Nov 24 '20 at 18:21