'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 |
