'SwiftUI - MVVM - nested components (array) and bindings understanding problem

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



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source