'How to pass published values between view models?

I'm trying to pass on a published value from one view model to another (i.e. child view model need access to source and be able to manipulate value). I bet it is simple, however, I can't seem to find the "correct" way of doing within the MVVM pattern.

I've tried using @Bindings, Binding<Value>. I managed to get it work with @EnvironmentObject, but that means the view has to handle it because I can't pass it on to view model where the logic should be (ultimately I want to manipulate the data stream in the child view model using Combine). What have I missed?

I have simplified the situation with the following playground code:

import SwiftUI
import PlaygroundSupport

class InitialViewModel: ObservableObject {
    @Published var selectedPerson: Person = Person(firstName: "", surname: "")
}

struct InitialView: View {
    @StateObject var viewModel = InitialViewModel()

    var body: some View {
        ButtonView(selectedPerson: Published(wrappedValue: viewModel.selectedPerson) )

        SelectedPersonView(selectedPerson: Published(wrappedValue: viewModel.selectedPerson))
    }
}

class ButtonViewModel: ObservableObject {
    @Published var selectedPerson: Person

    init(selectedPerson: Published<Person>) {
        self._selectedPerson = selectedPerson
    }

    func toggleSelectedPerson() {
        if selectedPerson.firstName.isEmpty {
            selectedPerson = Person(firstName: "Boris", surname: "Johnson")
        } else {
            selectedPerson = Person(firstName: "", surname: "")
        }
    }
}

struct ButtonView: View {
    @ObservedObject var viewModel: ButtonViewModel

    init(selectedPerson: Published<Person>) {
        self._viewModel = ObservedObject(wrappedValue: ButtonViewModel(selectedPerson: selectedPerson))
    }

    var body: some View {
        Button(action: { viewModel.toggleSelectedPerson()} ) {
            Text("Press to select person")
        }
    }
}

class SelectedPersonViewModel: ObservableObject {
    @Published var selectedPerson: Person

    init(selectedPerson: Published<Person>) {
        self._selectedPerson = selectedPerson
    }
}

struct SelectedPersonView: View {
    @ObservedObject var viewModel: SelectedPersonViewModel

    init(selectedPerson: Published<Person>) {
        self._viewModel = ObservedObject(wrappedValue: SelectedPersonViewModel(selectedPerson: selectedPerson))
    }

    var body: some View {
        if viewModel.selectedPerson.firstName.isEmpty {
            Text("No person selected yet")
        } else {
            Text("Person \(viewModel.selectedPerson.firstName) selected!")
        }
    }
}

struct Person {
    let firstName: String
    let surname: String
}

let view = InitialView()
PlaygroundPage.current.setLiveView(view)

In essence, when I press the button, the selectedPerson property should be updated and the view should update accordingly.

EDIT 19th August

Ok, in order to clarify the issue, I've added a very simplified version of the actual code I'm working on. Hopefully this explains as to why I'm looking at this problem.

NOTE: I'm aware of the compiling errors. This is just to demonstrate what I'm looking for.

struct ItemOption: Identifiable {
    let id: Int
    let name: String
    var dependentOn: [Int]? // dependencies where, choosing one option opens up more options
    let values: [ItemOptionValue]
}

struct ItemOptionValue: Identifiable {
    let id: Int
    let name: String?
}

class OptionViewModel: ObservableObject { // all options e.g. config options on a car
    @Published var selectedOptions = [Int:[Int]]() // Structure of [OptionID: [ValueID]
    @Published var allOptions = [ItemOption]()
    @Published var filteredOptions = [ItemOption]()
    
    init(options: [ItemOption]) {
        self.allOptions = options
        filterAvailableOptions()
    }
    
    func filterAvailableOptions() {
        // Combine code to filter viewable options depending on dependencies that may appear in selectedOption
    }
}

struct OptionView: View {
    @StateObject var viewModel: OptionViewModel
    
    init(options: [ItemOption]) {
        self.viewModel = OptionViewModel(options: options)
    }
    
    var body: some View {
        ForEach(viewModel.filteredOptions) { section in
            OptionTypeView(selectedOptions: viewModel.selectedOptions, optionValues: section.values)
        }
    }
}

class OptionTypeViewModel: ObservableObject { // each option type e.g. colour on car, wheel trims etc
    
}

struct OptionTypeView: View {
    var selectedOptions: [Int:[Int]]
    var optionValues: [ItemOptionValue]
    @StateObject var viewModel = OptionTypeViewModel()
    
    var body: some View {
        ForEach(optionValues) { value in
            OptionValueView(selectedOptions: selectedOptions)
        }
    }
}

class OptionValueViewModel: ObservableObject { // values of each option e.g. each colour choice
    @Published var isOptionSelected: Bool
    @Published var selectedOptions: [Int:[Int]] // passed on value (Binding?) from OptionViewModel
    
    init(selectedOptions: [Int:[Int]]) {
        self.selectedOptions = selectedOptions
    }
    
    func trackSelectedOptions(optionID: Int, valueID: Int) {
        $selectedOptions
        // ... Combine mapping to check if value exists in selectedOptions that originally comes from OptionViewModel
            .assign(\.isOptionSelected, on: self)
    }
    
    func removeOption() {
        // code to remove value id from selectedOptions
    }
    
    func addOption() {
        // code to add value id to selectedOptions
    }
}

struct OptionValueView: View {
    @StateObject var viewModel: OptionValueViewModel
    
    init(selectedOptions: [Int: [Int]]) {
        self.viewModel = OptionValueViewModel(selectedOptions: selectedOptions)
    }
    
    var body: some View {
        if viewModel.isOptionSelected {
            Text("Option is selected")
            Button(action: { viewModel.removeOption } ) {
                Text("Remove option")
            }
        } else {
            Text("Option is NOT selected")
            Button(action: { viewModel.addOption } ) {
                Text("Add option")
            }
        }
    }
}

One option would be to perhaps join it all together in one massive view (with view model) and func/computed variables handling the sub views in order to have access to the original value, but the problem remains that OptionValueView still needs to calculate it's own value in order to drive what's shown on the view, and this needs to be done in its own view model.



Solution 1:[1]

After coming across https://mokacoding.com/blog/swiftui-dependency-injection/, I think I've found a solution that works decently well, using a ViewModelFactory which creates the view models and directly injects the PersonController which is only created once. I could potentially even potentially create the ViewModelFactory in the InitialView as an EnvironmentObject and pass it on down the line to grandchild views if view models are needed to be created further down the line.

import SwiftUI
import Combine
import PlaygroundSupport

struct Person {
    let firstName: String
    let surname: String
}

class PersonController: ObservableObject {
    @Published var selectedPerson: Person = Person(firstName: "", surname: "")
}

class ViewModelFactory {
    let personController = PersonController()
    
    func makeButtonViewModel() -> ButtonViewModel {
        return ButtonViewModel(personController: personController)
    }
    
    func makeSelectedPersonViewModel() -> SelectedPersonViewModel {
        return SelectedPersonViewModel(personController: personController)
    }
}

class InitialViewModel: ObservableObject {
    let viewModelFactory = ViewModelFactory()
}

struct InitialView: View {
    @StateObject var viewModel = InitialViewModel()
    

    var body: some View {
        ButtonView(viewModel: viewModel.viewModelFactory.makeButtonViewModel())

        SelectedPersonView(viewModel: viewModel.viewModelFactory.makeSelectedPersonViewModel())
    }
}

class ButtonViewModel: ObservableObject {
    private let personController: PersonController

    init(personController: PersonController) {
        self.personController = personController
    }

    func toggleSelectedPerson() {
        if personController.selectedPerson.firstName.isEmpty {
            personController.selectedPerson = Person(firstName: "Boris", surname: "Johnson")
        } else {
            personController.selectedPerson = Person(firstName: "", surname: "")
        }
    }
}

struct ButtonView: View {
    @ObservedObject var viewModel: ButtonViewModel

    var body: some View {
        Button(action: { viewModel.toggleSelectedPerson()} ) {
            Text("Press to select person")
        }
    }
}

class SelectedPersonViewModel: ObservableObject {
    @Published var selectedPerson: Person
    
    private let personController: PersonController
    
    private var cancellables = Set<AnyCancellable>()

    init(personController: PersonController) {
        self.personController = personController
        
        selectedPerson = personController.selectedPerson
        
        personController.$selectedPerson
            .sink { [weak self] in
                self?.selectedPerson = $0
            }
            .store(in: &cancellables)
    }
}

struct SelectedPersonView: View {
    @ObservedObject var viewModel: SelectedPersonViewModel

    var body: some View {
        if viewModel.selectedPerson.firstName.isEmpty {
            Text("No person selected yet")
        } else {
            Text("Person \(viewModel.selectedPerson.firstName) selected!")
        }
    }
}

let view = InitialView()
PlaygroundPage.current.setLiveView(view)

Solution 2:[2]

I think you should rather do somthing like this

ButtonView(selectedPerson: viewModel._selectedPerson) )
SelectedPersonView(selectedPerson: viewModel._selectedPerson)

I have doubt wheter above will really propagae values from child to parent @Published

BUT:

You can just relay on closure passed to child view model, and this closure via its argument will propaget this events to parent viewmodel and set @Published

final class ChildViewModel { 

   init(onSelectedPerson: (Person) -> Void) { }

}

or use

PassthroughSubject

that you pass to child view.

Sources

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

Source: Stack Overflow

Solution Source
Solution 1 Aecasorg
Solution 2