swiftuiobservableobjectviper-architecture

SwiftUI View don't see property of ObservableObject marked with @Published


I'm writing my app using SwiftUI and VIPER. And to save the idea of viper(testability, protocols and etc) and SwiftUI reactivity I want to add 1 more layer - ViewModel. My presenter will ask data from interactor and will put in ViewModel, then view will just read this value.I checked does method that put data into view model works - and yes it does. But my view just don't see the property of view model (shows empty list) even if it conforms to ObservableObject and property is marked with Published. What is more interesting that if I store data in presenter and also mark it with published and observable object it will work. Thank in advance!

class BeersListPresenter: BeersListPresenterProtocol, ObservableObject{
    var interactor: BeersListInteractorProtocol
    @ObservedObject var viewModel = BeersListViewModel()
    
    init(interactor: BeersListInteractorProtocol){
        self.interactor = interactor
        
    }
    func loadList(at page: Int){
        interactor.loadList(at: page) { beers in
            DispatchQueue.main.async {
                self.viewModel.beers.append(contentsOf: beers)
                print(self.viewModel.beers)
            }
        }
    }


class BeersListViewModel:ObservableObject{
    @Published var beers = [Beer]()
}


struct BeersListView: View{
    var presenter : BeersListPresenterProtocol
    @StateObject var viewModel : BeersListViewModel
    var body: some View {
        NavigationView{
            List{
                ForEach(viewModel.beers, id: \.id){ beer in
                    HStack{
                        VStack(alignment: .leading){
                            Text(beer.name)
                                .font(.headline)
                            Text("Vol: \(presenter.formattedABV(beer.abv))")
                                .font(.subheadline)
                            
                        }

Solution

  • Some things to note.

    You can't chain ObservableObjects so @ObservedObject var viewModel = BeersListViewModel() inside the class won't work.

    The second you have 2 ViewModels one in the View and one in the Presenter you have to pick one. One will not know what the other is doing.

    Below is how to get your code working

    import SwiftUI
    struct Beer: Identifiable{
        var id: UUID = UUID()
        var name: String = "Hops"
        var abv: String = "H"
    }
    protocol BeersListInteractorProtocol{
        func loadList(at: Int, completion: ([Beer])->Void)
    }
    
    struct BeersListInteractor: BeersListInteractorProtocol{
        func loadList(at: Int, completion: ([Beer]) -> Void) {
            completion([Beer(), Beer(), Beer()])
        }
    }
    protocol BeersListPresenterProtocol: ObservableObject{
        var interactor: BeersListInteractorProtocol { get set }
        var viewModel : BeersListViewModel { get set }
        
        func formattedABV(_ abv: String) -> String
        func loadList(at page: Int)
    }
    class BeersListPresenter: BeersListPresenterProtocol, ObservableObject{
        var interactor: BeersListInteractorProtocol
        //You can't chain `ObservedObject`s
        @Published var viewModel = BeersListViewModel()
        
        init(interactor: BeersListInteractorProtocol){
            self.interactor = interactor
            
        }
        func loadList(at page: Int){
            interactor.loadList(at: page) { beers in
                DispatchQueue.main.async {
                    self.viewModel.beers.append(contentsOf: beers)
                    print(self.viewModel.beers)
                }
            }
        }
        func formattedABV(_ abv: String) -> String{
            "**\(abv)**"
        }
    }
    
    //Change to struct
    struct BeersListViewModel{
        var beers = [Beer]()
    }
    
    
    struct BeerListView<T: BeersListPresenterProtocol>: View{
        //This is what will trigger view updates
        @StateObject var presenter : T
        //The viewModel is in the Presenter
        //@StateObject var viewModel : BeersListViewModel
        var body: some View {
            NavigationView{
                List{
                    Button("load list", action: {
                        presenter.loadList(at: 1)
                    })
                    ForEach(presenter.viewModel.beers, id: \.id){ beer in
                        HStack{
                            VStack(alignment: .leading){
                                Text(beer.name)
                                    .font(.headline)
                                Text("Vol: \(presenter.formattedABV(beer.abv))")
                                    .font(.subheadline)
                                
                            }
                        }
                    }
                }
            }
        }
    }
    struct BeerListView_Previews: PreviewProvider {
        static var previews: some View {
            BeerListView(presenter: BeersListPresenter(interactor: BeersListInteractor()))
        }
    }
    

    Now I am not a VIPER expert by any means but I think you are mixing concepts. Mixing MVVM and VIPER.Because in VIPER the presenter Exists below the View/ViewModel, NOT at an equal level.

    I found this tutorial a while ago. It is for UIKit but if we use an ObservableObject as a replacement for the UIViewController and the SwiftUI View serves as the storyboard.

    It makes both the ViewModel that is an ObservableObject and the View that is a SwiftUI struct a single View layer in terms of VIPER.

    You would get code that looks like this

    protocol BeersListPresenterProtocol{
        var interactor: BeersListInteractorProtocol { get set }
        
        func formattedABV(_ abv: String) -> String
        func loadList(at: Int, completion: ([Beer]) -> Void)
        
    }
    struct BeersListPresenter: BeersListPresenterProtocol{
        var interactor: BeersListInteractorProtocol
        
        init(interactor: BeersListInteractorProtocol){
            self.interactor = interactor
            
        }
        func loadList(at: Int, completion: ([Beer]) -> Void) {
            
            interactor.loadList(at: at) { beers in
                completion(beers)
            }
        }
        func formattedABV(_ abv: String) -> String{
            "**\(abv)**"
        }
    }
    protocol BeersListViewProtocol: ObservableObject{
        var presenter: BeersListPresenterProtocol { get set }
        var beers: [Beer] { get set }
        func loadList(at: Int)
        func formattedABV(_ abv: String) -> String
        
    }
    class BeersListViewModel: BeersListViewProtocol{
        @Published var presenter: BeersListPresenterProtocol
        @Published var beers: [Beer] = []
        
        init(presenter: BeersListPresenterProtocol){
            self.presenter = presenter
        }
        func loadList(at: Int) {
            DispatchQueue.main.async {
                self.presenter.loadList(at: at, completion: {beers in
                    self.beers = beers
                })
            }
        }
        
        func formattedABV(_ abv: String) -> String {
            presenter.formattedABV(abv)
        }
    }
    struct BeerListView<T: BeersListViewProtocol>: View{
        @StateObject var viewModel : T
        
        var body: some View {
            NavigationView{
                List{
                    Button("load list", action: {
                        viewModel.loadList(at: 1)
                    })
                    ForEach(viewModel.beers, id: \.id){ beer in
                        HStack{
                            VStack(alignment: .leading){
                                Text(beer.name)
                                    .font(.headline)
                                Text("Vol: \(viewModel.formattedABV(beer.abv))")
                                    .font(.subheadline)
                                
                            }
                        }
                    }
                }
            }
        }
    }
    struct BeerListView_Previews: PreviewProvider {
        static var previews: some View {
            BeerListView(viewModel: BeersListViewModel(presenter: BeersListPresenter(interactor: BeersListInteractor())))
        }
    }
    

    If you don't want to separate the VIPER View Layer into the ViewModel and the SwiftUI View you can opt to do something like the code below but it makes it harder to replace the UI and is generally not a good practice. Because you WON'T be able to call methods from the presenter when there are updates from the interactor.

    struct BeerListView<T: BeersListPresenterProtocol>: View, BeersListViewProtocol{
        var presenter: BeersListPresenterProtocol
        @State var beers: [Beer] = []
        
        var body: some View {
            NavigationView{
                List{
                    Button("load list", action: {
                        loadList(at: 1)
                    })
                    ForEach(beers, id: \.id){ beer in
                        HStack{
                            VStack(alignment: .leading){
                                Text(beer.name)
                                    .font(.headline)
                                Text("Vol: \(formattedABV(beer.abv))")
                                    .font(.subheadline)
                                
                            }
                        }
                    }
                }
            }
        }
        func loadList(at: Int) {
            DispatchQueue.main.async {
                self.presenter.loadList(at: at, completion: {beers in
                    self.beers = beers
                })
            }
        }
        
        func formattedABV(_ abv: String) -> String {
            presenter.formattedABV(abv)
        }
    }
    struct BeerListView_Previews: PreviewProvider {
        static var previews: some View {
            BeerListView<BeersListPresenter>(presenter: BeersListPresenter(interactor: BeersListInteractor()))
        }
    }