Lavair
Lavair

Reputation: 936

How can I use the @Binding Wrapper in the following mvvm architecture?

I've set up a mvvm architecture. I've got a model, a bunch of views and for each view one single store. To illustrate my problem, consider the following:
In my model, there exists a user object user and two Views (A and B) with two Stores (Store A, Store B) which both use the user object. View A and View B are not dependent on each other (both have different stores which do not share the user object) but are both able to edit the state of the user object. Obviously, you need to propagate somehow the changes from one store to the other. In order to do so, I've built a hierarchy of stores with one root store who maintains the entire "app state" (all states of shared objects like user). Now, Store A and B only maintain references on root stores objects instead of maintaining objects themselves. I'd expected now, that if I change the object in View A, that Store A would propagate the changes to the root store which would propagate the changes once again to Store B. And when I switch to View B, I should be able now to see my changes. I used Bindings in Store A and B to refer to the root stores objects. But this doesn't work properly and I just don't understand the behavior of Swift's Binding. Here is my concrete set up as a minimalistic version:

public class RootStore: ObservableObject {
    @Published var storeA: StoreA?
    @Published var storeB: StoreB?
    
    @Published var user: User
    
    init(user: User) {
        self.user = user
    }
}

extension ObservableObject {
    func binding<T>(for keyPath: ReferenceWritableKeyPath<Self, T>) -> Binding<T> {
        Binding(get: { [unowned self] in self[keyPath: keyPath] },
                set: { [unowned self] in self[keyPath: keyPath] = $0 })
    }
}

public class StoreA: ObservableObject {
    @Binding var user: User

    init(user: Binding<User>) {
        _user = user
    }
}

public class StoreB: ObservableObject {
    @Binding var user: User

    init(user: Binding<User>) {
        _user = user
    }
}

In my SceneDelegate.swift, I've got the following snippet:

    user = User()
    let rootStore = RootStore(user: user)
    let storeA = StoreA(user: rootStore.binding(for: \.user))
    let storeB = StoreB(user: rootStore.binding(for: \.user))
    
    rootStore.storeA = storeA
    rootStore.storeB = storeB
    
    let contentView = ContentView()
        .environmentObject(appState) // this is used for a tabView. You can safely ignore this for this question
        .environmentObject(rootStore)

then, the contentView is passed as a rootView to the UIHostingController. Now my ContentView:

struct ContentView: View {
    @EnvironmentObject var appState: AppState
    @EnvironmentObject var rootStore: RootStore
    
    var body: some View {
        TabView(selection: $appState.selectedTab) {
            ViewA().environmentObject(rootStore.storeA!).tabItem {
                Image(systemName: "location.circle.fill")
                Text("ViewA")
            }.tag(Tab.viewA)

            ViewB().environmentObject(rootStore.storeB!).tabItem {
                Image(systemName: "waveform.path.ecg")
                Text("ViewB")
            }.tag(Tab.viewB)
        }
    }
}

And now, both Views:

struct ViewA: View {
    // The profileStore manages user related data
    @EnvironmentObject var storeA: StoreA
    
    var body: some View {
        Section(header: HStack {
            Text("Personal Information")
            Spacer()
            Image(systemName: "info.circle")
        }) {
            TextField("First name", text: $storeA.user.firstname)
        }
    }
}

struct ViewB: View {
    @EnvironmentObject var storeB: StoreB
    
    var body: some View {
        Text("\(storeB.user.firstname)")
    }
}

Finally, my issue is, that changes are just not reflected as they are supposed to be. When I change something in ViewA and switch to ViewB, I don't see the updated first name of the user. When I change back to ViewA my change is also lost. I used didSet inside the stores and similar for debugging purposes and the Binding actually seems to work. The change is propagated but somehow the View just doesn't update. I also forced with some artificial state changing (adding a state bool variable and just toggling it in an onAppear()) that the view rerenders but still, it doesn't take the updated value and I just don't know what to do.

EDIT: Here is a minimal version of my User object

public struct User {
    public var id: UUID?
    public var firstname: String
    public var birthday: Date

    public init(id: UUID? = nil,
                firstname: String,
                birthday: Date? = nil) {
        self.id = id
        self.firstname = firstname
        self.birthday = birthday ?? Date()
    }
}

For simplicity, I didn't pass the attributes in the SceneDelegate.swift snippet above.

Upvotes: 2

Views: 267

Answers (2)

Jim lai
Jim lai

Reputation: 1409

Providing an alternative answer here with some changes to your design as a comparison.

  1. The shared state here is the user object. Put it in @EnvironmentObject, which is by definition the external state object shared by views in the hierarchy. This way you don't need to notify StoreA which notifies RootStore which then notifies StoreB.

  2. Then StoreA, StoreB can be local @State, and RootStore is not required. Store A, B can be value types since there's nothing to observe.

  3. Since @EnvironmentObject is by definition an ObservableObject, we don't need User to conform to ObservableObject, and can thus make User a value type.

     final class EOState: ObservableObject {
    
         @Published var user = User()
    
     }
    
     struct ViewA: View {
         @EnvironmentObject eos: EOState
         @State storeA = StoreA()
         // ... TextField("First name", text: $eos.user.firstname)
     }
    
     struct ViewB: View {
         @EnvironmentObject eos: EOState
         @State storeB = StoreB()
         // ... Text("\(eos.user.firstname)")
     }
    

The rest should be straight-forward.

What is the take-away in this comparison?

  1. Should avoid objects observing each other, or a long publish chain. It's confusing, hard to track, and not scalable.

  2. MVVM tells you nothing about managing state. SwiftUI is most powerful when you've learnt how to allocate and manage your states. MVVM however heavily relies upon @ObservedObject for binding, because iOS had no binding. For beginners this is dangerous, because it needs to be reference type. The result might be, as in this case, overuse of reference types which defeats the whole purpose of a SDK built around value types.

  3. It also removes most of the boilerplate init codes, and one can focus on 1 shared state object instead of 4.

  4. If you think SwiftUI creators are idiots, SwiftUI is not scalable and requires MVVM on top of it, IMO you are sadly mistaken.

Upvotes: 0

Asperi
Asperi

Reputation: 257493

In your scenario it is more appropriate to have User as-a ObservableObject and pass it by reference between stores, as well as use in corresponding views explicitly as ObservedObject.

Here is simplified demo combined from your code snapshot and applied the idea.

Tested with Xcode 11.4 / iOS 13.4

enter image description here

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let user = User(id: UUID(), firstname: "John")
        let rootStore = RootStore(user: user)
        let storeA = StoreA(user: user)
        let storeB = StoreB(user: user)
        rootStore.storeA = storeA
        rootStore.storeB = storeB

        return ContentView().environmentObject(rootStore)
    }
}

public class User: ObservableObject {
    public var id: UUID?
    @Published public var firstname: String
    @Published public var birthday: Date

    public init(id: UUID? = nil,
                firstname: String,
                birthday: Date? = nil) {
        self.id = id
        self.firstname = firstname
        self.birthday = birthday ?? Date()
    }
}

public class RootStore: ObservableObject {
    @Published var storeA: StoreA?
    @Published var storeB: StoreB?

    @Published var user: User

    init(user: User) {
        self.user = user
    }
}

public class StoreA: ObservableObject {
    @Published var user: User

    init(user: User) {
        self.user = user
    }
}

public class StoreB: ObservableObject {
    @Published var user: User

    init(user: User) {
        self.user = user
    }
}

struct ContentView: View {
    @EnvironmentObject var rootStore: RootStore

    var body: some View {
        TabView {
            ViewA(user: rootStore.user).environmentObject(rootStore.storeA!).tabItem {
                Image(systemName: "location.circle.fill")
                Text("ViewA")
            }.tag(1)

            ViewB(user: rootStore.user).environmentObject(rootStore.storeB!).tabItem {
                Image(systemName: "waveform.path.ecg")
                Text("ViewB")
            }.tag(2)
        }
    }
}

struct ViewA: View {
    @EnvironmentObject var storeA: StoreA    // keep only if it is needed in real view

    @ObservedObject var user: User
    init(user: User) {
        self.user = user
    }

    var body: some View {
        VStack {
            HStack {
                Text("Personal Information")
                Image(systemName: "info.circle")
            }
            TextField("First name", text: $user.firstname)
        }
    }
}

struct ViewB: View {
    @EnvironmentObject var storeB: StoreB

    @ObservedObject var user: User
    init(user: User) {
        self.user = user
    }

    var body: some View {
        Text("\(user.firstname)")
    }
}

Upvotes: 2

Related Questions