1

I just stuck trying to properly implement MVVM pattern in SwiftUI

I got such application

Project structure

ContainerView is the most common view. It contains single business View Model object - ItemsViewModel and one surrogate - Bool to toggle it's value and force re-render of whole View. ItemsView contains of array of business objects - ItemView

What I want is to figure out:

  1. how to implement bindings which actually works❓
  2. call back event and pass value from child view to parent❓

I came from React-Redux world, it done easy there. But for the several days I cant figure out what should I do... I chose MVVM though as I also got some WPF experience and thought it'll give some boost, there its done with ObservableCollection for bindable arrays

ContainerViewModel.swift⤵️

final class ContainerViewModel: ObservableObject {
    @Published var items: ItemsViewModel;

    // variable used to refresh most common view
    @Published var refresh: Bool = false;
    
    init() {
        self.items = ItemsViewModel();
    }
    
    func buttonRefresh_onClick() -> Void {
        self.refresh.toggle();
    }
    
    func buttonAddItem_onClick() -> Void {
        self.items.items.append(ItemViewModel())
    }
}

ContainerView.swift⤵️

struct ContainerView: View {
    // enshure that enviroment creates single View Model of ContainerViewModel with StateObject for ContainerView
    @StateObject var viewModel: ContainerViewModel = ContainerViewModel();
    
    var body: some View {
        ItemsView(viewModel: $viewModel.items).padding()
        Button(action: viewModel.buttonAddItem_onClick) {
            Text("Add item from ContainerView")
        }
        Button(action: viewModel.buttonRefresh_onClick) {
            Text("Refresh")
        }.padding()
    }
}

ItemsViewModel.swift⤵️

final class ItemsViewModel: ObservableObject {
    @Published var items: [ItemViewModel] = [ItemViewModel]();
    
    init() {
        
    }
    
    func buttonAddItem_onClick() -> Void {
        self.items.append(ItemViewModel());
    }
}

ItemsView.swift⤵️

struct ItemsView: View {
    @Binding var viewModel: ItemsViewModel;
    
    var body: some View {
        Text("Items quantity: \(viewModel.items.count)")
        ScrollView(.vertical) {
            ForEach($viewModel.items) { item in
                ItemView(viewModel: item).padding()
            }
        }
        Button(action: viewModel.buttonAddItem_onClick) {
            Text("Add item form ItemsView")
        }
    }
}

ItemViewModel.swift⤵️

final class ItemViewModel: ObservableObject, Identifiable, Equatable {
    //implementation of Identifiable
    @Published public var id: UUID = UUID.init();
    
    // implementation of Equatable
    static func == (lhs: ItemViewModel, rhs: ItemViewModel) -> Bool {
        return lhs.id == rhs.id;
    }
    
    // business property
    @Published public var intProp: Int;
    
    init() {
        self.intProp = 0;
    }
    
    func buttonIncrementIntProp_onClick() -> Void {
        self.intProp = self.intProp + 1;
    }
    
    func buttonDelete_onClick() -> Void {
        //todo ❗ I want to delete item in parent component
    }
}

ItemView.swift⤵️

struct ItemView: View {
    @Binding var viewModel: ItemViewModel;
    
    var body: some View {
        HStack {
            Text("int prop: \(viewModel.intProp)")
            Button(action: viewModel.buttonIncrementIntProp_onClick) {
                Image(systemName: "plus")
            }
            Button(action: viewModel.buttonDelete_onClick) {
                Image(systemName: "trash")
            }
        }.padding().border(.gray)
    }
}

Here is the demo

I read official docs and countless SO topics and articles, but nowhere got solution for exact my case (or me doing something wrong). It only works if implement all UI part in single view

UPD 1:

Is it even possible to with class but not a struct in View Model? Updates works perfectly if I use struct instead of class:

ItemsViewModel.swift⤵️

struct ItemsViewModel {
    var items: [ItemViewModel] = [ItemViewModel]();
    
    init() {
        
    }
    
    mutating func buttonAddItem_onClick() -> Void {
        self.items.append(ItemViewModel());
    }
}

ItemViewModel.swift⤵️

struct ItemViewModel: Identifiable, Equatable {
    //implementation of Identifiable
    public var id: UUID = UUID.init();
    
    // implementation of Equatable
    static func == (lhs: ItemViewModel, rhs: ItemViewModel) -> Bool {
        return lhs.id == rhs.id;
    }
    
    // business property
    public var intProp: Int;
    
    init() {
        self.intProp = 0;
    }
    
    mutating func buttonIncrementIntProp_onClick() -> Void {
        self.intProp = self.intProp + 1;
    }
    
    func buttonDelete_onClick() -> Void {
        
    }
}

But is it ok to use mutating functions? I also tried to play with Combine and objectWillChange, but unable to make it work

UPD 2 Thanks @Yrb for response. With your suggestion and this article I came added Model structures and ended up with such results:

ContainerView.swift⤵️

struct ContainerView: View {
    // enshure that enviroment creates single View Model of ContainerViewModel with StateObject for ContainerView
    @StateObject var viewModel: ContainerViewModel = ContainerViewModel();
    
    var body: some View {
        ItemsView(viewModel: ItemsViewModel(viewModel.items)).padding()
        Button(action: viewModel.buttonAddItem_onClick) {
            Text("Add item from ContainerView")
        }
    }
}

ContainerViewModel.swift⤵️

final class ContainerViewModel: ObservableObject {
    @Published var items: ItemsModel;
    
    init() {
        self.items = ItemsModel();
    }
    
    @MainActor func buttonAddItem_onClick() -> Void {
        self.items.items.append(ItemModel())
    }
}

ContainerModel.swift⤵️

struct ContainerModel {
    public var items: ItemsModel;   
}

ItemsView.swift⤵️

struct ItemsView: View {
    @ObservedObject var viewModel: ItemsViewModel;
    
    var body: some View {
        Text("Items quantity: \(viewModel.items.items.count)")
        ScrollView(.vertical) {
            ForEach(viewModel.items.items) { item in
                ItemView(viewModel: ItemViewModel(item)).padding()
            }
        }
        Button(action: {
            viewModel.buttonAddItem_onClick()
        }) {
            Text("Add item form ItemsView")
        }
    }
}

ItemsViewModel.swift⤵️

final class ItemsViewModel: ObservableObject {
    @Published var items: ItemsModel;
    
    init(_ items: ItemsModel) {
        self.items = items;
    }
    
    @MainActor func buttonAddItem_onClick() -> Void {
        self.items.items.append(ItemModel());
    }
}

ItemsModel.swift⤵️

struct ItemsModel {
    public var items: [ItemModel] = [ItemModel]();
}

ItemView.swift⤵️

struct ItemView: View {
    @StateObject var viewModel: ItemViewModel;
    
    var body: some View {
        HStack {
            Text("int prop: \(viewModel.item.intProp)")
            Button(action: {
                viewModel.buttonIncrementIntProp_onClick()
            }) {
                Image(systemName: "plus")
            }
            Button(action: {
                viewModel.buttonDelete_onClick()
            }) {
                Image(systemName: "trash")
            }
        }.padding().border(.gray)
    }
}

ItemViewModel.swift⤵️

final class ItemViewModel: ObservableObject, Identifiable, Equatable {
    //implementation of Identifiable
    @Published private(set) var item: ItemModel;
    
    // implementation of Equatable
    static func == (lhs: ItemViewModel, rhs: ItemViewModel) -> Bool {
        return lhs.id == rhs.id;
    }
    
    init(_ item: ItemModel) {
        self.item = item;
        self.item.intProp = 0;
    }
    
    @MainActor func buttonIncrementIntProp_onClick() -> Void {
        self.item.intProp = self.item.intProp + 1;
    }
    
    @MainActor func buttonDelete_onClick() -> Void {
        
    }
}

ItemModel.swift⤵️

struct ItemModel: Identifiable {
    //implementation of Identifiable
    public var id: UUID = UUID.init();
    
    // business property
    public var intProp: Int;
    
    init() {
        self.intProp = 0;
    }
}

This code runs and works perfectly, at least I see no problems. But I'm not shure if I properly initializes and "bind" ViewModels and Models - code looks quite messy. Also I'n not shure I correctly set ObservedObject in ItemsView and StateObject in ItemView. So please check me

BankAngle
  • 33
  • 7
  • 1
    The issue you are having is a nested view model class problem. An `@Published ` var only can be bound in a view. To handle nested view models, you would have to subscribe to the `@Published` var in the nested view model [like this](https://stackoverflow.com/a/58406402/7129318). However, you are much better off modeling your data structure as a struct and then using that in your view model. You will save yourself a lot of grief. – Yrb Mar 22 '22 at 12:49
  • Every `ObservableObject` has to be wrapped in an `@ObservedObject`. – lorem ipsum Mar 22 '22 at 12:54
  • @loremipsum how then can I bind it (pass to child component) and use in View? – BankAngle Mar 22 '22 at 13:04
  • You can use `@Binding ` on the "value" variables of an `ObservableObject` every `class` that is an `ObservableObject` has to be wrapped. – lorem ipsum Mar 22 '22 at 13:28
  • @Yrb thank you, please, check UPD 2 if I did everything correct – BankAngle Mar 22 '22 at 14:27
  • @loremipsum yep, but in this case I cannot trigger to re-render view until parent view is re-rendered. In this case I should subscribe to changes in child component, isn't I? – BankAngle Mar 22 '22 at 14:32
  • 1
    Because of the long chain you might need to call `objectWillChange.send()` after you add/append. But realize that will all of these interconnected view models you will have a troubleshooting nightmare as your app grows. MVVM implemented like this where one view model depends on a child or a child's child is a horrible practice, There is plenty of talk on SO on this topic. A `View` should be the only once concerned with its `ViewModel` a `ViewModel` should never care about another `ViewModel` – lorem ipsum Mar 22 '22 at 14:47

1 Answers1

-1

We don't need MVVM in SwiftUI, see: "MVVM has no place in SwiftUI."

In SwiftUI, the View struct is already the view model. We use property wrappers like @State and @Binding to make it behave like an object. If you were to actually use objects instead, then you'll face all the bugs and inconsistencies of objects that SwiftUI and its use of value types was designed to eliminate.

I recommend Data Essentials in SwiftUI WWDC 2020 for learning SwiftUI. The first half is about view data and the second half is about model data. It takes a few watches to understand it. Pay attention to the part about model your data with value type and manage its life cycle with a reference type.

It's best to also use structs for your model types. Start with one single ObservableObject to hold the model types (usually arrays of structs) as @Published properties. Use environmentObject to pass the object into the View hierarchy. Use @Binding to pass write access to the structs in the object. Apple's sample ScrumDinger is a good starting point.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • People are hung up on the `ViewModel` label. What the OP is is calling a `ViewModel` is no different than a `CoreData` object, its relationships and their objects. "You need to use structs for your model type" and "square peg in a round hole" are not quite correct statements. It may seem that way but if that was true all CoreData objects would be `struct`s. People mixing and misunderstanding "value" vs "reference" pre-dates SwiftUI. The `@StateObject` in this sample is very similar to `@FetchRequest` and everything below it is pretty much the same to a CoreData based app. – lorem ipsum Mar 22 '22 at 15:38
  • I'm not talking about MVVM by the way, that is irrelevant I'm just talking about the setup. I just think people are hung up on "ViewModel". The setup they are trying to use where a parent depends on a child of a child is the part that is wrong in any pattern. – lorem ipsum Mar 22 '22 at 15:50
  • @loremipsum FetchRequest is a DynamicProperty struct that can use @Environment and has an update func called every time before body. It's not possible to simulate that with `@StateObject`. – malhal Mar 22 '22 at 16:03
  • That is why I said very similar. The `@StateObject` is holding the `ObservbleObject`'s. In both cases you need to use `@ObservedObject` to see the changes of the individual objects. `buttonAddItem_onClick` is what is wrong, it is trying to affect a child of a child and have the `Container` react. – lorem ipsum Mar 22 '22 at 16:08
  • 1
    after some time digging around acceptable solution I find out that MVVM is quite difficult for swift, at least for swift-begginer like me. So I finally switched to approach which implemented in ScrumDinger, so @malhal thank you for you tip. The resulting code is a bit smelly, I mean not as well structured , but app works fast and stable with no unnecessary re-renders. Thank everyone for your opinion and experience! – BankAngle Jul 01 '22 at 13:41
  • Great news, you're very welcome! – malhal Jul 01 '22 at 21:33