swiftmvvmswiftuiviewmodelobservedobject

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!

Solution

  • 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
    }