How to Share Published Model Between Two View Models in Swiftui

How to share published model between two view models in SwiftUI?

Ok, Model is struct, so it is just copied when you pass it from ContentViewModeltoContentView2Model` via

ContentView2(model: contentView1Model.model)

This is the case when it is more preferable to have model as standalone ObservableObject, so it will be passed by reference from one view model into another.

class Model: ObservableObject {
@Published var name = ""
}

and then you can inject it and modify in any needed subview, like

struct ContentView1: View {
@StateObject var contentView1Model = ContentView1Model()
var body: some View {
NavigationView {
VStack{
ModelEditView(model: contentView1Model.model) // << !!
NavigationLink(destination: ContentView2(model: contentView1Model.model)){
Text("ToContentView2")
}
}
}
}
}

struct ContentView2: View {
@StateObject var contentView2Model: ContentView2Model

init(model: Model) {
self._contentView2Model = StateObject(wrappedValue: ContentView2Model(model: model))
}

var body: some View {
ModelEditView(model: contentView2Model.model) // << !!
}
}

struct ModelEditView: View {
@ObservedObject var model: Model

var body: some View {
TextField("ModelName", text: $model.name)
}
}

How to share data between models in SwiftUI?

I've found two solutions to my problem and have opted for the second one listed below.

Option 1: Nest views so that "Alerts" is initialized in the body of the parent view

This solution is detailed in this Stack Overflow question.

This solution seems a bit hacky, but maybe I'm misguided in that thinking and someone can correct this sentiment. Regardless, here is a quick code snippet:

struct HomeView: View {
@EnvironmentObject var user: User

var body: some View {
HomeInternalView(Alerts(userId: user.id))
}
}

struct HomeInternalView: View {
@ObservedObject alerts: Alerts

init(alerts) {
self.alerts = alerts
}

var body: some View {
// ...
}
}

Option 2: Make User into a singleton

For my specific case, I realized that the "User" model is needed for every view in the app as well as some models (Alerts). So instead of passing an environment object to every view, I now have a "UserService" and I can access the UserService directly from Alerts.

As per this Stack Overflow question, if a model is needed for every view in the app then using a singleton is acceptable.

class UserService: ObservableObject {
static let shared: UserService = UserService()

private init() {
// initializations ..
}
}

struct HomeView: View {
@ObservedObject var alerts: Alerts
@ObservedObject private var user = UserService.shared

init() {
self.alerts = Alerts()
}
// ...
}

class Alerts: ObservableObject {
@ObservedObject private var user = UserService.shared
// ...
}

Communication between ViewModels with SwiftUI and Combine (ObservableObject vs Binding)

I would like to suggest a few improvements to your architecture.

DISCLAIMER:
Note that the following implementation is a suggestion how to approach the Master-Detail problem. There are countless more approaches and this one is just one of severals which I would suggest.

When things become more complex, you probably would prefer a unidirectional data flow approach between your view model and view. This basically means, no two way bindings to the view state.

Unidirectional means, that your SwiftUI views deal basically with constant external state which they render without asking. Instead of mutating the backing variable from a two way Binding directly, views send actions (aka events) to the view model. The view model processes theses events and sends out a new view state taking the whole logic into account.

By the way, this unidirectional data flow is inherent to the MVVM pattern. So, when you use a View Model, you should not use two way bindings that mutate the "View State". Otherwise this would not be MVVM and using the term View Model would be incorrect or at least confusing.

The consequence is, that your views will not perform any logic, all logic is delegated to the view model.

In your Master - Detail problem, this also means, that NavigationLinks will not be directly performed by the Master View. Instead, the fact that a user has tapped the NavigationLink will be send to the view model as an action. The view model then decides, whether to show a detail view, or not, or demands to show an alert, a modal sheet, or what ever it deems necessary what the view has to render.

Likewise, if the user taps the "back" button, the view will not be immediately popped from the Navigation stack. Instead, the view model receives an action. Again, it decides what to do.

This approach lets you intercept the data flow at strategically important "locations" and let you handle the situation more easily and in a correct way.

In a Master-Detail problem, especially in your example where architectural decisions are yet to be made, there is always the question who (which component) is responsible to create a Detail View Model (if required) and which part composes the Detail View and the Detail View Model and dynamically puts this into the view system (somehow) and removes it again when done (if required).

If we make the proposition, that a View Model should create a Detail View Model, which IMHO is reasonable, and if we further assume, that a user can issue an action that eventually ends up showing a detail view and with the suggestions made before, a possible solution in SwiftUI may look as follows:

(Note, I will not use your example, but create a new one with more generic names. So, hopefully, you can see where your example maps into my example)

So, we need these parts

  • a master view
  • a master view model,
  • a detail view
  • a detail view model
  • possibly additional views for decomposing several aspects and separation of concerns

The master view:

struct MasterView: View {
let items: [MasterViewModel.Item]
let selection: MasterViewModel.Selection?
let selectDetail: (_ id: MasterViewModel.Item.ID) -> Void
let unselectDetail: () -> Void

...

The master view uses a "state" which is comprised of the items it should draw in a list view. Additionally, it has two action functions selectDetail and unselectDetail. I am pretty sure it is clear, what these mean, but we will see later how they get used by the master view.

Additionally, we have a Selection property, which is an optional, and you might guess what it means: when it is not nil, it will render the detail view. If it is nil, it does not render a detail view. Pretty easy. Again, hold on where we see how it is used and what it is precisely.

When we look at the body of the master view, we implement the NavigationLink in a special form, so that we fulfil our unidirectional data flow requirement:

    var body: some View {
List {
ForEach(items, id: \.id) { element in
NavigationLink(
tag: element.id,
selection: link()) {
if let selection = self.selection {
DetailContainerView(
viewModel: selection.viewModel)
}
} label: {
Text("\(element.name)")
}
}
}
}

The NavigationLink uses the "selectable destination" form, whose signature is

init<V>(tag: V, selection: Binding<V?>, destination: () -> Destination, label: () -> Label)

This creates a navigation link that presents the destination view when a bound selection variable equals a given tag value.

See docs here.

The tag is the unique id of the item (here element.id).
The selection parameter, which is a Binding<Item.ID?> is the result of the function link() which will be shown below:

    func link() -> Binding<MasterViewModel.Item.ID?> {
Binding {
self.selection?.id
} set: { id in
print("link: \(String(describing: id))")
if let id = id {
selectDetail(id)
} else {
unselectDetail()
}
}
}

As you can see, link returns the proper binding. However, one crucial fact you can see here is, that we do not use "two way bindings". Instead, we route the actions that would mutate the backing variable of the binding to action functions. These actions will eventually be performed by the view model, which we will see later.

Please note the two action functions:

selectDetail(:) and

unselectDetail()

The getter of the binding works as usual: it just returns the id of the item.

This above, and the implementation of the two actions are enough to make push and pop from the Navigation stack work.

Need to edit the items, or pass some data from the Detail View to the Master View?
Just use this:

unselectDetail(mutatedItem: Item)

and an internal @Sate var item: Items in the Detail View plus logic in the Detail View Controller, or let the master and detail view model communicate to each other (see below).

With these parts, the Master View is complete.

But what is this Selection thingy?

This value will be created by the Master View Model. It is defined as follows:

    struct Selection: Identifiable {
var id: Item.ID
var viewModel: DetailViewModel
}

So, pretty easy. What's important to note is, that there is a Detail View Model. And since the Master View Model creates this "selection", it also has to create the detail view model - as our proposition has stated above.

Here, we make the assumption, that a view model has enough information at hand at the right time to create a fully configured detail (or child) view model.

The Master View Model

This view model has a few responsibilities. I will show the code, which should be pretty self-explanatory:

final class MasterViewModel: ObservableObject {

struct ViewState {
var items: [Item] = []
var selection: Selection? = nil
}

struct Item: Identifiable {
var id: Int
var name: String
}

struct Selection: Identifiable {
var id: Item.ID
var viewModel: DetailViewModel
}

@Published private(set) var viewState: ViewState

init(items: [Item]) {
self.viewState = .init(items: items, selection: nil)
}

func selectDetail(id: Item.ID) {
guard let item = viewState.items.first(where: { id == $0.id } ) else {
return
}
let detailViewModel = DetailViewModel(
item: .init(id: item.id,
name: item.name,
description: "description of \(item.name)",
image: URL(string: "a")!)
)
self.viewState.selection = Selection(
id: item.id,
viewModel: detailViewModel)
}

func unselectDetail() {
self.viewState.selection = nil
}
}

So, basically, it has a ViewState, which is precisely the "single source of truth" from the perspective of the view which has to just render this thing, without asking any questions.

This view state also contains the "Selection" value. Honestly, we may debate whether this is part of the view state or not, but I made it short, and put it into the view state, and thus, the view model only publishes one value, the View State. This makes this implementation more suitable to be refactored into a generic ..., but I don't want to distress.

Of course, the view model implements the effects of the action functions

selectDetail(:) and unselect().

It also has to create the Detail View Model. In this example, it just fakes it.

There's not much else to do for a Master View Model.

Detail View

The detail view is just for demonstration and as short as possible:

struct DetailView: View {
let item: DetailViewModel.Item

var body: some View {
HStack {
Text("\(item.id)")
Text("\(item.name)")
Text("\(item.description)")
}
}
}

You may notice, that it uses a constant view state (let item).
In your example, you may want to have actions, like "save" or something that is performed by the user.

Detail View Model

Also, pretty simple. Here, in your problem, you may want to put more logic in which handles the user's actions.

final class DetailViewModel: ObservableObject {

struct Item: Identifiable {
var id: Int
var name: String
var description: String
var image: URL
}

struct ViewState {
var item: Item
}

@Published private(set) var viewState: ViewState

init(item: Item) {
self.viewState = .init(item: item)
}

}

Caution: over simplification!

Here, in this example, the two view models don't communicate with each other. In a more practical solution, you may have more complicated things to solve, which involve communication between these view models. You would likely not implement this directly in the View Models, rather implement "Stores" which have Inputs, State, and possibly Outputs, perform their logic using finite state machines, and which can be interconnected so you have a system of "States" which eventually comprise your "AppState" which publishes its state to the view models, which in turn transform this to the view state for their views.

Wiring up

Here, some helper views come into play. They just help to wire up the view models with the views:

struct DetailContainerView: View {
@ObservedObject private(set) var viewModel: DetailViewModel

var body: some View {
DetailView(item: viewModel.viewState.item)
}
}

This sets up the view state, BUT ALSO separates the Detail View from the Detail View Model, since the view does not need to know anything about a view model. This makes it more easy to reuse the DetailView as a component.

struct MasterContainerView: View {
@ObservedObject private(set) var viewModel: MasterViewModel

var body: some View {
MasterView(
items: viewModel.viewState.items,
selection: viewModel.viewState.selection,
selectDetail: viewModel.selectDetail(id:),
unselectDetail: viewModel.unselectDetail)
}
}

Same here, decouple the MasterView from the MasterViewModel and setup actions and view state.

For your playgrounds:

struct ContentView: View {
@StateObject var viewModel = MasterViewModel(items: [
.init(id: 1, name: "John"),
.init(id: 2, name: "Bob"),
.init(id: 3, name: "Mary"),
])

var body: some View {
NavigationView {
MasterContainerView(viewModel: viewModel)
}
.navigationViewStyle(.stack)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())

Have fun! ;)

Pass data from one ViewModel to another in SwiftUI?

Can you describe a bit what you are trying to do?

If you want to pass the view model, you can us the same view model in both views:

struct V_NamesList: View {
@ObservedObject var vm: VM_App

var body: some View {
ForEach(Array(vm.names.enumerated()), id: \.element) { (i, name) in
TextField("New Player", text: $vm.names[i])
}
}
}

and you can pass the view model from the parent:

struct V_App: View {
@StateObject var vm: VM_App = VM_App()

var body: some View {
V_NamesList(vm: vm)
}
}

you will need to use @StateObject when you create and store the observable object view model.



Related Topics



Leave a reply



Submit