0
votes

I have a MVVM SwiftUI app that will navigate to another view based on the value of a @Published property of a view model:

class ViewModel: ObservableObject {
    @Published public var showView = false
    
    func doShowView() {
        showView = true
    }
}

struct MyView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
          NavigationView {
             MySubView().environmentObject(viewModel)
          }
    }
}

struct MySubView: View {
    @EnvironmentObject private var viewModel: ViewModel
    
    var body: some View {
        VStack {
            Button(action: {
                viewModel.doShowView()
            }) {
                Text("Button")
            }
        
            NavigationLink(
                destination: SomeOtherView(),
                isActive: $viewModel.showView,
                label: {
                    EmptyView()
                })
        }
    }
}

The problem is sometimes when I run the app it will work only every other time and sometimes it works perfectly as expected.

The cause seems to be that sometimes when the property is set in the view model (in doShowView()) SwiftUI will immediately render my view with the old value of showView and in the working case the view is rendered on the next event cycle with the updated value.

Is this a feature (due to the fact @Published is calling objectWillChange under the hood and the view is rendering due to that) or a bug?

If it is a feature (and I just happen to get lucky when it works as I want it to) what is the best way to guarantee it renders my view after the new value is set?

Note this is only a simple example, I cannot use a @State variable in the button action since in the real code the doShowView() method may or may not set the showView property in the view model.

2

2 Answers

0
votes

The issue here is that SwiftUI creates the SomeOtherView beforehand. Then, this view is not related with the viewModel in any way, so it's not re-created when viewModel.showView changes.

A possible solution is to make SomeOtherView depend on the viewModel - e.g. by explicitly injecting the environmentObject:

struct MySubView: View {
    @EnvironmentObject private var viewModel: ViewModel

    var body: some View {
        VStack {
            Button(action: {
                viewModel.doShowView()
            }) {
                Text("Button")
            }
            NavigationLink(
                destination: SomeOtherView().environmentObject(viewModel),
                isActive: $viewModel.showView,
                label: {
                    EmptyView()
                }
            )
        }
    }
}
0
votes

I came upon a working solution. I did add a @State variable and set it by explictly watching for changes of showView:

class ViewModel: ObservableObject {
    @Published public var showView = false
    var disposables = Set<AnyCancellable>()
    
    func doShowView() {
        showView = true
    }
}

struct MyView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
          NavigationView {
             MySubView().environmentObject(viewModel)
          }
    }
}

struct MySubView: View {
    @EnvironmentObject private var viewModel: ViewModel
    @State var showViewLink = false
    
    var body: some View {
        VStack {
            Button(action: {
                viewModel.doShowView()
            }) {
                Text("Button")
            }
        
            NavigationLink(
                destination: SomeOtherView(),
                isActive: $showViewLink,
                label: {
                    EmptyView()
                })
        }
        .onAppear {
            viewModel.$showView
                .sink(receiveValue: { showView in
                    showViewLink = showView
                })
            .store(in: &viewModel.disposables)
        }
    }
}