SwiftUI Binding property from view model to a value outside of that view model

I'm having an issue with a Binding property inside of a view model that's being created in another view model. Here is simplified version of 2 views:

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()
    
    var body: some View {
        VStack {
            // binding through `@Published var toggleViewModel`
            ToggleWrapperView(viewModel: viewModel.toggleViewModel)
            
            // binding through `var isOnBinding: Binding<Bool>`
            ToggleWrapperView(viewModel: ToggleViewModel(isOn: viewModel.isOnBinding!, title: "Binding through isOnBinding"))
            
            // binding through `@Published var isOn`
            ToggleWrapperView(viewModel: ToggleViewModel(isOn: $viewModel.isOn, title: "Binding through view"))
        }
        .padding()
    }
}

struct ToggleWrapperView: View {
    var viewModel: ToggleViewModel
    var body: some View {
        VStack {
            Toggle(isOn: viewModel.$isOn, label: {
                Text(viewModel.title + " \(viewModel.isOn)")
            })
        }
    }
}

And here are simple view models for the views:

class ContentViewModel: ObservableObject {
    @ObservedObject var dataManager = DataManager()
    @Published var toggleViewModel: ToggleViewModel!
    
    @Published var isOn: Bool = false // Property for the binding in the ContentView

    private var privateIsOnForBinding: Bool = false
    var isOnBinding: Binding<Bool>?
    
    init() {
        toggleViewModel = ToggleViewModel(isOn: $dataManager.isOn, title: "Binding from DataManager")
        initBinding()
    }
    
    func initBinding() {
        isOnBinding = Binding {
            self.privateIsOnForBinding
        } set: { updatedValue in
            self.privateIsOnForBinding = updatedValue
            print(self.isOnBinding!)
        }
    }
}

struct ToggleViewModel {
    @Binding var isOn: Bool
    let title: String
}

class DataManager: ObservableObject {
    @Published var isOn: Bool = false {
        didSet {
            print("did set DataManager.isOn \(isOn)")
        }
    }
}

From the ToggleWrapperView examples above only binding created with $viewModel.isOn is working as expected on toggle action.

In the example with toggleViewModel I can see that the property in DataManager is being updated (print in the property observer), but the binding itself stays false

Binding<Bool>(transaction: SwiftUI.Transaction(plist: []), location: SwiftUI.LocationBox<SwiftUI.(unknown context at $1c5c1c4dc).ObjectLocation<BindingTest.DataManager, Swift.Bool>>, _value: false)

And using var isOnBinding: Binding<Bool> gives me the same result, binding is not being updated

Binding<Bool>(transaction: SwiftUI.Transaction(plist: []), location: SwiftUI.LocationBox<SwiftUI.FunctionalLocation<Swift.Bool>>, _value: false)

This is just a simplified example, but I'd like to understand why 2 versions don't work. I can make the example with toggleViewModel to work if I mark var viewModel: ToggleViewModel as @Binding in the ToggleWrapperView, and remove the @Binding from the isOn property of ToggleViewModel (first example in the view). However I'm trying to have the binding for the isOn between ToggleViewModel and DataManager that's being passed in the ContentViewModel if possible.

Answered by szymczyk in 781385022

I don't have any simplified approach for you. I can tell you two things.

First, to pass a view model to a SwiftUI view, do the following:

  • Have the view model conform to ObservableObject.
  • Add @Published properties in the view model for any properties where you want the view to update when the property's value changes.
  • Use @StateObject in the view that owns the view model.
  • Use @ObservedObject in the other views where you want to use the view model.

Second, avoid nesting observable objects. SwiftUI views may not update properly when a property in a nested observable object has its value change. If you are unfamiliar with nested observable objects, read the following article:

https://holyswift.app/how-to-solve-observable-object-problem/

Bindings are for SwiftUI views. Using @Binding in a view model isn't going to work. Creating a binding like Binding<Bool> in a view model isn't going to work either.

For passing data from a view model to a SwiftUI view, you are on the right track with the following code in ContentViewModel:

class ContentViewModel: ObservableObject {
    @ObservedObject var dataManager = DataManager()    
    @Published var isOn: Bool = false 
}

Have the view model conform to ObservableObject. Use @Published for any properties where you want the view to update when the property's value changes. The view that owns the view model uses the @StateObject property wrapper, like you have in your content view.

@StateObject var viewModel = ContentViewModel()

Use the @ObservedObject property wrapper to pass the view model from the content view to other views.

I am not sure why the content view model needs its own isOn property. Can't it use the data manager's isOn property?

I've used 2 different isOn properties just to separate the examples of ToggleWrapperView in the ContentView. In real example it will be only in the DataManager.

In actual implementation I have a more complex view with multiple text editors, toggles, and other views to show, so I have multiple smaller reusable views that could be configured with different color, font, etc. DataManager is a type specific for this view and responsible for fetching remote data, applying some local state from Core Data to that remote data and creating multiple structs to be presented in the view. Those structs don't contain presentation data (color, font, etc).

ContentViewModel will use those structs from DataManager and create view models for the reusable view components (by adding some presentation data: font, color, background, etc). The reason I've tried to use @Binding in a ToggleViewModel is to connect the data from DataManager to the editable property of the view. Like when user makes a change to the text in text editor or changes the value of the toggle, those changes would propagate to the struct properties inside of DataManager. But since @Binding won't work inside of a view model struct, I would have to use something like this:


struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()
    
    var body: some View {
        VStack {
            ToggleWrapperView(viewModel: $viewModel.toggleViewModel)
        }
        .padding()
    }
}

#Preview {
    ContentView()
}


struct ToggleWrapperView: View {
    @Binding var viewModel: ToggleViewModel
    var body: some View {
        VStack {
            Toggle(isOn: $viewModel.isOn, label: {
                Text(viewModel.title + " \(viewModel.isOn)")
            })
        }
    }
}

class ContentViewModel: ObservableObject {
    @ObservedObject var dataManager = DataManager()
    @Published var toggleViewModel: ToggleViewModel! {
        didSet {
            guard dataManager.dataStruct.isOn != toggleViewModel.isOn else { return }
            dataManager.dataStruct.isOn = toggleViewModel.isOn
        }
    }
    
    init() {
        // In real world this will initialized after DataManager fetches the data
        toggleViewModel = ToggleViewModel(isOn: dataManager.dataStruct.isOn, title: "Some title")
    }
}

struct ToggleViewModel {
    var isOn: Bool
    let title: String
    // This could have some other properties related to configuration of the text and color of the toggle, etc
}

class DataManager: ObservableObject {
    // This will be initialized in async method with remote data, etc
    @Published var dataStruct: SomeDataStruct = .init(isOn: false)
    
    struct SomeDataStruct {
        var isOn: Bool
        // This could have some other data properties
    }
}

With the binding in the view model I was trying to skip the step of manually updating the data in DataManager when ToggleViewModel changes from the action on the view. ToggleViewModel here is only for example, it could be a view model for reusable text editor view, or some other custom views. Also for this example I don't really need the DataManager to be ObservableObject, I might need it in more complex case.

I made a mistake in my answer. The @ObservedObject property wrapper is for SwiftUI views. You can't use it in a view model. The following declaration:

@ObservedObject var dataManager = DataManager()

Should be something like

@Published var dataManager = DataManager()

I am not sure what you are asking in your second response. You described what you are trying to do but didn't ask a question.

Thanks for the replies @szymczyk . I'll remove the @ObservedObject from the dataManager. My only question would be do you see anything that could be simplified in the approach in my response?

Accepted Answer

I don't have any simplified approach for you. I can tell you two things.

First, to pass a view model to a SwiftUI view, do the following:

  • Have the view model conform to ObservableObject.
  • Add @Published properties in the view model for any properties where you want the view to update when the property's value changes.
  • Use @StateObject in the view that owns the view model.
  • Use @ObservedObject in the other views where you want to use the view model.

Second, avoid nesting observable objects. SwiftUI views may not update properly when a property in a nested observable object has its value change. If you are unfamiliar with nested observable objects, read the following article:

https://holyswift.app/how-to-solve-observable-object-problem/

SwiftUI Binding property from view model to a value outside of that view model
 
 
Q