swiftswiftuicombine

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


This is a general question about SwiftUI and the architecture, so I'll take a simple but problematic example.

Initial project :

I have a first View which displays a list of Items. This list is managed by a class (which I called ListViewModel here). In a second view I can modify one of these Items, and save these modifications with a "save" button. In a simplified version, I can do this easily using @Binding. Thanks SwiftUI:

struct ListView: View {
    @StateObject var vm = ListViewModel()
    var body: some View {
        NavigationView {
            List(Array(vm.data.enumerated()), id: \.1.id) { index, item in
                NavigationLink(destination: DetailView(item: $vm.data[index])) {
                    Text(item.name)
                }
            }
        }
    }
}

struct DetailView: View {
    @Binding var initialItem: Item
    @State private var item: Item
    init(item: Binding<Item>) {
        _item = State(initialValue: item.wrappedValue)
        _initialItem = item
    }
    var body: some View {
        VStack {
            TextField("name", text: $item.name)
            TextField("description", text: $item.description)
            Button("save") {
                initialItem = item
            }
        }
    }
}

struct Item: Identifiable {
    let id = UUID()
    var name: String
    var description: String
    static var fakeItems: [Item] = [.init(name: "My item", description: "Very good"), .init(name: "An other item", description: "not so bad")]
}

class ListViewModel: ObservableObject {
    @Published var data: [Item] = Item.fakeItems
    func fetch() {}
    func save() {}
    func sort() {}
}

Problem :

Things get more complicated when the detail / edit view gets more complex. Its number of properties increases, we must set up code that does not concern the View (networking, storage, etc.), possibly an FSM, so we have another class to manage the DetailView (in my example: DetailViewModel).

And now the communication between the two Views, which was so easy with the @Binding becomes complicated to set up. In our example, these two elements are not linked, so we have to set up a two-way-binding :

class ListViewModel: ObservableObject {
    @Published var data: [Item]     <-----------
    func fetch() {}                             |
    func save() {}                              |
    func sort() {}                              |
}                                               | /In Search Of Binding/
                                                |
class DetailViewModel: ObservableObject {       |
    @Published var initialItem: Item <----------
    @Published var item: Item
                                                
    init(item: Item) {
        self.initialItem = item
        self.item = item
    }
    func fetch() {}
    func save() {
        self.initialItem = item
    }
}

Attempts

1. An array of DetailViewModels in the ListViewModel + Combine

Rather than storing an Array of Item, my ListViewModel could store a [DetailViewModel]. So during initialization it could subscribe to changes on DetailViewModels :

class ListViewModel: ObservableObject {
    @Published var data: [DetailViewModel]
    var bag: Set<AnyCancellable> = []

    init(items: [Item] = Item.fakeItems) {
        data = items.map(DetailViewModel.init(item:))
        subscribeToItemsChanges()
    }
    func subscribeToItemsChanges() {
        data.enumerated().publisher
            .flatMap { (index, detailVM) in
                detailVM.$initialItem
                    .map{ (index, $0 )}
            }
            .sink { [weak self] index, newValue in
                self?.data[index].item = newValue
                self?.objectWillChange.send()
            }
            .store(in: &bag)
    }
}

Results : Ok, that works, although it's not really a two-way-binding. But is it really relevant that a ViewModel contains an array of other ViewModels? a) It smells weird. b) We have an array of references (and no data types). c) we end up with that in the View:

List(Array(vm.data.enumerated()), id: \.1.item.id) { index, detailVM in
                NavigationLink(destination: DetailView(vm: detailVM)) {
                    Text(detailVM.item.name)
                }
            }

2. Give to DetailViewModel the reference of ListViewModel (Delegate style)

Since the DetailViewModel does not contain the array of Items, and since the Item it handles no longer has a @Binding: we could pass the ListViewModel (which contains the array) to each DetailViewModel.

protocol UpdateManager {
    func update(_ item: Item, at index: Int)
}

class ListViewModel: ObservableObject, UpdateManager {
    @Published var data: [Item]
    init(items: [Item] = Item.fakeItems) {
        data = items
    }
    func update(_ item: Item, at index: Int) {
        data[index] = item
    }
}

class DetailViewModel: ObservableObject {
    @Published var item: Item
    private var updateManager: UpdateManager
    private var index: Int
    init(item: Item, index: Int, updateManager: UpdateManager) {
        self.item = item
        self.updateManager = updateManager
        self.index = index
    }
    func fetch() {}
    func save() {
        updateManager.update(item, at: index)
    }
}

Results : It works but : 1) It seems like an old way which doesn't quite match the style of SwiftUI. 2) We must pass the index of the Item to the DetailViewModel.

3. Use a closure

Rather than passing a reference to the entire ListViewModel, we could pass a closure (onSave) to the DetailViewModel.

class ListViewModel: ObservableObject {
    @Published var data: [Item]
    init(items: [Item] = Item.fakeItems) {
        data = items
    }
    func update(_ item: Item, at index: Int) {
        data[index] = item
    }
}

class DetailViewModel: ObservableObject {
    @Published var item: Item
    var update: (Item) -> Void
    init(item: Item, onSave update: @escaping (Item) -> Void) {
        self.item = item
        self.update = update
    }
    func fetch() {}
    func save() {
        update(item)
    }
}

Results: On one hand it still looks like an old approach, on the other hand it seems to match the "one view - one ViewModel" approach. If we use an FSM we could imagine sending an Event / Input.

Variant: We can use Combine and pass a PassthroughSubject rather than a closure :

class ListViewModel: ObservableObject {
    @Published var data: [Item]
    var archivist = PassthroughSubject<(Int, Item), Never>()
    var cancellable: AnyCancellable?
    init(items: [Item] = Item.fakeItems) {
        data = items
        cancellable = archivist
            .sink {[weak self ]index, item in
                self?.update(item, at: index)
            }
    }
    func update(_ item: Item, at index: Int) {
        data[index] = item
    }
}

class DetailViewModel: ObservableObject {
    @Published var item: Item
    var index: Int
    var archivist: PassthroughSubject<(Int, Item), Never>
    init(item: Item, saveWith archivist: PassthroughSubject<(Int, Item), Never>, at index: Int) {
        self.item = item
        self.archivist = archivist
        self.index = index
    }
    func fetch() {}
    func save() {
        archivist.send((index, item))
    }
}

Question :

I could also have used an @Binding in my ObservableObject, or even wrapped my Item array in an other ObservableObject (and therefore have an OO in an OO). But it seems even less relevant to me.

In any case, everything seems very complicated as soon as we leave a simple Model-View architecture: where a simple @Binding is enough.

So I ask for your help : What do you recommend for this kind of scenario? What do you think is the most suitable for SwiftUI? Can you think of a better way?


Solution

  • 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

    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! ;)