Reputation: 569
I have this rather common situation where a List
is displaying some items from an @ObservedObject
data store as NavigationLinks
.
On selecting a NavigationLink
a DetailView is presented. This view has a simple Toggle
conected to a @Published
var on its ViewModel class.
When the DetailView appears (onAppear:
) its View Model sets the published var
that controls the Toggle
to true
and also triggers an async request that will update the main data store, causing the List
in the previous screen to update too.
The problem is that when this happens (the List
is reloaded from an action triggered in the Detail View) multiple instances of the DetailViewModel seem to retained and the DetailView starts receiving events from the wrong Publishers.
In the first time the Detail screen is reached the behaviour is correct (as shown in the code below), the toggle is set to true
and the store is updated, however, on navigating back to the List
and then again to another DetailView the toggle is set to true
on appearing but this time when the code that reloads the store executes, the toggle is set back to false
.
My understanding is that when the List
is reloaded and a new DetailView and ViewModel are created (as the destination of the NavigationLink
) the initial value from the isOn
variable that controls the Toggle
(which is false
) is somehow triggering an update to the Toggle
of the currently displayed screen.
Am I missing something here?
import SwiftUI
import Combine
class Store: ObservableObject {
static var shared: Store = .init()
@Published var items = ["one", "two"]
private init() { }
}
struct ContentView: View {
@ObservedObject var store = Store.shared
var body: some View {
NavigationView {
List(store.items, id: \.self) { item in
NavigationLink(item, destination: ItemDetailView())
}
}
}
}
struct ItemDetailView: View {
@ObservedObject var viewModel = ItemDetailViewModel()
var body: some View {
VStack {
Toggle("A toggle", isOn: $viewModel.isOn)
Text(viewModel.title)
Spacer()
} .onAppear(perform: viewModel.onAppear)
}
}
class ItemDetailViewModel: ObservableObject {
@ObservedObject var store: Store = .shared
@Published var isOn = false
var title: String {
store.items[0]
}
func onAppear() {
isOn = true
asyncOperationThatUpdatesTheStoreData()
}
private func asyncOperationThatUpdatesTheStoreData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.store.items = ["three", "four"]
}
}
}
Upvotes: 1
Views: 555
Reputation: 1392
You're controlling lifetimes and objects in a way that's not a pattern in this UI framework. The VieModel is not going to magically republish a singleton value type; it's going to instantiate with that value and then mutate its state without ever checking in with the shared instance again, unless it is rebuilt.
class Store: ObservableObject {
static var shared: Store = .init()
struct ContentView: View {
@ObservedObject var store = Store.shared
struct ItemDetailView: View {
@ObservedObject var viewModel = ItemDetailViewModel()
class ViewModel: ObservableObject {
@Published var items: [String] = Store.shared.items
There are lots of potential viable patterns. For example:
Create class RootStore: ObservableObject
and house it as an @StateObject in App. The lifetime of a StateObject is the lifetime of that view hierarchy's lifetime. You can expose it (a) directly to child views by .environmentObject(store) or (b) create a container object, such as Factory that vends ViewModels and pass that via the environment without exposing the store to views, or (c) do both a and b.
If you reference the store in another class, hold a weak reference weak var store: Store?
. If you keep state in that RootStore on @Published, then you can subscribe directly to that publisher or to the RootStore's own ObjectWillChangePublisher. You could also use other Combine publishers like CurrentValueSubject to achieve the same as @Published.
For code examples, see
Setting a StateObject value from child view causes NavigationView to pop all views
SwiftUI @Published Property Being Updated In DetailView
Upvotes: 1