Lucas Pereira
Lucas Pereira

Reputation: 569

SwiftUI List data update from destination view causes unexpected behaviour

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

Answers (1)

Ryan
Ryan

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:

  1. 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.

  2. 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

Related Questions