Jonas Bäumer
Jonas Bäumer

Reputation: 72

How to trigger automatic SwiftUI Updates with @ObservedObject using MVVM

I have a question regarding the combination of SwiftUI and MVVM.

Before we start, I have read some posts discussing whether the combination of SwiftUI and MVVM is necessary. But I don't want to discuss this here, as it has been covered elsewhere. I just want to know if it is possible and, if yes, how. :)

So here comes the code. I tried to add the ViewModel Layer in between the updated Object class that contains a number that should be updated when a button is pressed. The problem is that as soon as I put the ViewModel Layer in between, the UI does not automatically update when the button is pressed.

View:


struct ContentView: View {
    
    @ObservedObject var viewModel = ViewModel()
    @ObservedObject var numberStorage = NumberStorage()
    
    var body: some View {
        VStack {
//            Text("\(viewModel.getNumberObject().number)")
//                .padding()
//            Button("IncreaseNumber") {
//                viewModel.increaseNumber()
//            }
            Text("\(numberStorage.getNumberObject().number)")
                .padding()
            Button("IncreaseNumber") {
                numberStorage.increaseNumber()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

ViewModel:


class ViewModel: ObservableObject {
    
    @Published var number: NumberStorage
    
    init() {
        self.number = NumberStorage()
    }
    
    func increaseNumber() {
        self.number.increaseNumber()
    }
    
    func getNumberObject() -> NumberObject {
        self.number.getNumberObject()
    }
    
} 

Model:


class NumberStorage:ObservableObject {
    @Published var numberObject: NumberObject
    
    init() {
        numberObject = NumberObject()
    }
    
    public func getNumberObject() -> NumberObject {
        return self.numberObject
    }
    
    public func increaseNumber() {
        self.numberObject.number+=1
    }
}

struct NumberObject: Identifiable {
    let id = UUID()
    var number = 0
} ```

Looking forward to your feedback!

Upvotes: 2

Views: 2496

Answers (4)

Ruchin Somal
Ruchin Somal

Reputation: 1103

I have two approaches to handle this scenario

  1. Using ObservableObject:
class ViewModel: ObservableObject {
    @Published var isSelected: Bool = false
}
struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        Toggle("Select", isOn: $viewModel.isSelected)
            .onReceive(viewModel.$isSelected) { newValue in
                print("isSelected changed to \(newValue)")
            }
    }
}
  1. Without ObservableObject:
class ViewModel {
    @Published var isSelected: Bool = false
}
struct ContentView: View {
    private var viewModel = ViewModel()
    @State private var isSelected: Bool = false
    private var cancellable: AnyCancellable?

    var body: some View {
        Toggle("Select", isOn: $isSelected)
            .onChange(of: isSelected) { newValue in
                viewModel.isSelected = newValue
            }
            .onReceive(viewModel.$isSelected) { newValue in
                isSelected = newValue
                print("isSelected changed to \(newValue)")
            }
    }
}

In summary, while using @Published with onReceive without ObservableObject can provide more flexibility and decoupling, it comes at the cost of additional boilerplate and potential complexity. The choice depends on your specific needs and the architecture of your application.

Upvotes: 0

ibrahimyilmaz
ibrahimyilmaz

Reputation: 2335

It's a known problem. Nested observable objects are not supported yet in SwiftUI. I don't think you need ViewModel+Model here since ViewModel seems to be enough.

To make this work you have to trigger objectWillChange of your viewModel manually when objectWillChange of your model is triggered:

class ViewModel: ObservableObject {
    init() {
        number.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }.store(in: &cancellables)
    }
}

You better listen to only the object you care not the whole observable class if it is not needed.

Plus:

Since instead of injecting, you initialize your viewModel in your view, you better use StateObject instead of ObservedObject. See the reference from Apple docs: Managing model data in your app

Upvotes: 1

Pastre
Pastre

Reputation: 743

I think your code is breaking MVVM, as you're exposing to the view a storage model. In MVVM, your ViewModel should hold only two things:

  1. Values that your view should display. These values should be automatically updated using a binding system (in your case, Combine)
  2. Events that the view may produce (in your case, a button tap) Having that in mind, your ViewModel should wrap, adapt and encapsulate your model. We don't want model changes to affect the view. This is a clean approach that does that: View:

struct ContentView: View {
    
    @StateObject // When the view creates the object, it must be a state object, or else it'll be recreated every time the view is recreated
    private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Text("\(viewModel.currentNumber)") // We don't want to use functions here, as that will create a new object , as SwiftUI needs the same reference in order to keep track of changes
                .padding()
            Button("IncreaseNumber") {
                viewModel.increaseNumber()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

ViewModel:


class ViewModel: ObservableObject {
    
    @Published
    private(set) var currentNumber: Int = 0 // Private set indicates this should only be mutated by the viewmodel
    private let numberStorage = NumberStorage()
    
    init() {
        numberStorage.currentNumber
            .map { $0.number }
        .assign(to: &$currentNumber) // Here we're binding the current number on the storage to the published var that the view is listening to.`&$` basically assigns it to the publishers address
    }
    
    func increaseNumber() {
        self.numberStorage.increaseNumber()
    }
}

Model:

class NumberStorage {
    private let currentNumberSubject = CurrentValueSubject<NumberObject, Never>(NumberObject())

    var currentNumber: AnyPublisher<NumberObject, Never> {
        currentNumberSubject.eraseToAnyPublisher()
    }
    
   func increaseNumber() {
       let currentNumber = currentNumberSubject.value.number
       currentNumberSubject.send(.init(number: currentNumber + 1))
    }
}


struct NumberObject: Identifiable { // I'd not use this, just send and int directly
    let id = UUID()
    var number = 0
}

Upvotes: 4

dillon-mce
dillon-mce

Reputation: 69

One way you could handle this is to observe the publishers in your Storage class and send the objectWillChange publisher when it changes. I have done this in personal projects by adding a class that all my view models inherit from which provides a nice interface and handles the Combine stuff like this:

Parent ViewModel

import Combine

class ViewModel: ObservableObject {
    private var cancellables: Set<AnyCancellable> = []

    func publish<T>(on publisher: Published<T>.Publisher) {
        publisher.sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)
    }
}

Specific ViewModel

class ContentViewModel: ViewModel {
    private let numberStorage = NumberStorage()

    var number: Int { numberStorage.numberObject.number }

    override init() {
        super.init()
        publish(on: numberStorage.$numberObject)
    }

    func increaseNumber() {
        numberStorage.increaseNumber()
    }
}

View

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            Text("\(viewModel.number)")
                .padding()
            Button("IncreaseNumber") {
                viewModel.increaseNumber()
            }
        }
    }
}

Model/Storage

class NumberStorage:ObservableObject {
    @Published var numberObject: NumberObject

    init() {
        numberObject = NumberObject()
    }

    public func increaseNumber() {
        self.numberObject.number += 1
    }
}

struct NumberObject: Identifiable {
    let id = UUID()
    var number = 0
}

This results in the view re-rendering any time Storage.numberObject changes.

Upvotes: 0

Related Questions