Diego A. Rincon
Diego A. Rincon

Reputation: 776

@StateObject preventing view updates and @ObservedObject initialises a new view model in every render

I want to use the MVVM approach to separate UI presentation logic from the view presentation itself. I'm doing it like this:

struct ViewA: View {
    @StateObject var viewModel: ViewModel
    
    init(titleList: [String]) {
        let viewModel = ViewModel(titleList: titleList)
        self._viewModel = .init(wrappedValue: viewModel)
    }
    
    var body: some View {
        HStack {
            ForEach(viewModel.titleList, id: \.self) { title in
                Text(title)
            }
        }
    }
}

The controller looks like this:

extension ViewA {
    class ViewModel: ObservableObject {
        let titleList: [String]
        
        init(titleList: [String]) {
            self.titleList = titleList
        }
    }
}

However the view does NOT update initialise a new view model when titleList changes. This causes the view to not be updated since the list is being accessed through the controller. If I change @StateObject to @ObservedObject the view is updated as expected although this means there's a new view model created on every render even if title didn't change.

Let's say the view model is doing some intensive operations with the title. I don't want to trigger these operations on every render, but ONLY when the title changes. What's the best way to keep the state of the view model across redraws IF the title list remains the same and update the view model IF it changes?

Upvotes: 0

Views: 54

Answers (1)

Try this approach using one source of truth for the data model and updating its property in .onAppear, such as this working example code:

struct ContentView: View {
    var body: some View {
        ViewA(titleList: ["one", "two", "three"]) //<-- here
    }
}

struct ViewA: View {
    @StateObject private var viewModel = ViewModel(titleList: []) //<-- here
    let titleList: [String] //<-- here

    init(titleList: [String]) {
        self.titleList = titleList //<-- here
    }
    
    var body: some View {
        HStack {
            ForEach(viewModel.titleList, id: \.self) { title in
                Text(title)
            }
        }
        Button("Add another title") { //<-- for testing
             viewModel.titleList.append(String(UUID().uuidString.prefix(6)))
        }.buttonStyle(.borderedProminent)
        .onAppear {
            viewModel.titleList = titleList //<-- here
        }
    }
}

extension ViewA {
    class ViewModel: ObservableObject {
        @Published var titleList: [String] //<-- here
        
        init(titleList: [String]) {
            self.titleList = titleList
        }
    }
}

Another approach is to have the source of truth in the parent view, and pass this data model to the other views using, for example .environmentObject(viewModel), as shown in the example code:

See also Monitoring data for how to use ObservableObject in your App.

struct ContentView: View {
    @StateObject private var viewModel = ViewModel(titleList: []) //<-- here
    
    var body: some View {
        ViewA(titleList: ["one", "two", "three"])
            .environmentObject(viewModel) //<-- here
    }
}

struct ViewA: View {
    @EnvironmentObject var viewModel: ViewModel //<-- here
    let titleList: [String]
    
    init(titleList: [String]) {
        self.titleList = titleList
    }
    
    var body: some View {
        HStack {
            ForEach(viewModel.titleList, id: \.self) { title in
                Text(title)
            }
        }
        Button("Add another title") { // <-- for testing
            viewModel.titleList.append(String(UUID().uuidString.prefix(6)))
        }.buttonStyle(.borderedProminent)
        
            .onAppear {
                viewModel.titleList = titleList // <-- here
            }
    }
}

class ViewModel: ObservableObject {
    @Published var titleList: [String] //<-- here
    
    init(titleList: [String]) {
        self.titleList = titleList
    }
}

Upvotes: 0

Related Questions