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 Item
s. This list is managed by a class (which I called ListViewModel
here). In a second view I can modify one of these Item
s, 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 DetailViewModel
s :
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 Item
s, 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?