ValYouW
ValYouW

Reputation: 749

SwiftUI pass Binding by ref to a child ViewModel

In SwiftUI, I am trying to create some binding between a parent ViewModel and a child ViewModel, here is a simplified example of my scenario:

The parent component:

class ParentViewModel : ObservableObject {
    @Published var name = "John Doe"

    func updateName() {
        self.name = "Jonnie Deer"
    }
}

struct ParentView: View {
    @StateObject var viewModel = ParentViewModel()

    var body: some View {
        VStack {
            Text(viewModel.name)
            ChildView(name: $viewModel.name)
            // tapping the button the text on parent view is updated but not in child view
            Button("Update", action: viewModel.updateName)
        }
    }
}

The child component:

class ChildViewModel : ObservableObject {
    var name: Binding<String>
    
    var displayName: String {
        get {
            return "Hello " + name.wrappedValue
        }
    }
    
    init(name: Binding<String>) {
        self.name = name
    }
}

struct ChildView: View {
    @StateObject var viewModel: ChildViewModel

    var body: some View {
        Text(viewModel.displayName)
    }
    
    init(name: Binding<String>) {
        _viewModel = StateObject(wrappedValue: ChildViewModel(name: name))
    }
}

So, as stated in the comments, when I tap the button on the parent component the name is not getting updated in ChildView, as if the binding is lost... Is there any other way to update view model with the updated value? say something like getDerivedStateFromProps in React (becuase when tapping the button the ChildView::init method is called with the new name.

Thanks.

Upvotes: 7

Views: 5217

Answers (3)

malhal
malhal

Reputation: 30746

In SwiftUI View structs are a View model already so you can just remove those view model objects and do @State var name = “John” in ParentView and @Binding var name: String in ChildView. Then pass $name into ChildView’s init which gives you write access as if ParentView was a view model object (let if you only need read access).

By using @State and @Binding you get the reference type semantics you want inside a value type which is the power of SwiftUI. If you just use objects you lose that benefit and have more work to do.

We usually only use ObservableObject for loading/saving models but we can also use it for async loaders/fetchers where we want to tie some controller behaviour to the view on screen lifecycle (thats why it would be called async) but for data transient to a view we always use @State and @Binding. You can extract related vars into their own struct and use mutating funcs for other logic and thus have a single @State struct used by body instead of multiple. This way it can still be testable like a view model object in UIKit would be.

Logic, i.e. funcs, can be declared anywhere in Swift, it is not efficient to make a class just for logic. Just change the func from something that updates a class's property to one that returns something.

Upvotes: 2

Sven A
Sven A

Reputation: 1

My solution was to hold the name in a state variable in the ChildView. So something like this:

class ChildViewModel : ObservableObject {
    // only handle the logic here and pass the name as a parameter each time :( 
}

struct ChildView: View {
    @StateObject var viewModel = ChildViewModel()

    @Binding var displayName: String

    var body: some View {
        Text(displayName)
    }
}

I'm not really happy with this solution, but it's the only way I found so far to keep the viewModels separate and not recreating the ChildViewModel with each redraw.

Upvotes: 0

Yrb
Yrb

Reputation: 9725

Apple is very big on the concept of a Single Source of Truth(SSoT), and keeping it in mind will keep you from getting into the weeds in code like this. The problem you are having is that while you are using a Binding to instantiate the child view, you are turning around and using it as a @StateObject. When you do that, you are breaking the connection as @StateObject is supposed to sit at the top of the SSoT hierarchy. It designates your SSoT. Otherwise, you have two SSoTs, so you can only update one. The view model in ChildView should be an @ObservedObject so that it connects back up the hierarchy. Also, you can directly instantiate the ChildViewModel when you call ChildView. The initializer just serves to decouple things. Your views would look like this:

struct ParentView: View {
    @StateObject var viewModel = ParentViewModel()

    var body: some View {
        VStack {
            Text(viewModel.name)
            // You can directly use the ChildViewModel to instantiate the ChildView
            ChildView(viewModel: ChildViewModel(name: $viewModel.name))
            Button("Update", action: viewModel.updateName)
        }
    }
}

struct ChildView: View {
    // Make this an @ObservedObject not a @StateObject
    @ObservedObject var viewModel: ChildViewModel

    var body: some View {
        Text(viewModel.displayName)
    }
}

Neither view model is changed.

Upvotes: 4

Related Questions