45

Aim:

I have a model which is an ObservableObject. It has a Bool property, I would like to use this Bool property to initialise a @Binding variable.

Questions:

  1. How to convert an @ObservableObject to a @Binding ?
  2. Is creating a @State the only way to initialise a @Binding ?

Note:

  • I do understand I can make use of @ObservedObject / @EnvironmentObject, and I see it's usefulness, but I am not sure a simple button needs to have access to the entire model.
  • Or is my understanding incorrect ?

Code:

import SwiftUI
import Combine
import SwiftUI
import PlaygroundSupport

class Car : ObservableObject {

    @Published var isReadyForSale = true
}

struct SaleButton : View {

    @Binding var isOn : Bool

    var body: some View {

        Button(action: {

            self.isOn.toggle()
        }) {
            Text(isOn ? "On" : "Off")
        }
    }
}

let car = Car()

//How to convert an ObservableObject to a Binding
//Is creating an ObservedObject or EnvironmentObject the only way to handle a Observable Object ?

let button = SaleButton(isOn: car.isReadyForSale) //Throws a compilation error and rightly so, but how to pass it as a Binding variable ?

PlaygroundPage.current.setLiveView(button)
user1046037
  • 16,755
  • 12
  • 92
  • 138

4 Answers4

55

Binding variables can be created in the following ways:

  1. @State variable's projected value provides a Binding<Value>
  2. @ObservedObject variable's projected value provides a wrapper from which you can get the Binding<Subject> for all of it's properties
  3. Point 2 applies to @EnvironmentObject as well.
  4. You can create a Binding variable by passing closures for getter and setter as shown below:
let button = SaleButton(isOn: .init(get: { car.isReadyForSale },
                                    set: { car.isReadyForSale = $0} ))

Note:

  • As @nayem has pointed out you need @State / @ObservedObject / @EnvironmentObject / @StateObject (added in SwiftUI 2.0) in the view for SwiftUI to detect changes automatically.
  • Projected values can be accessed conveniently by using $ prefix.
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 7
    I found several useful / needed answers related to SwiftUI by @Asperi - Stay contributing :thumbs-up: – Hasaan Ali Jan 17 '21 at 17:07
  • 1
    As way 2, you can direct get `Binding ` by `$car.isReadyForSale` – yuanjilee Feb 20 '21 at 07:39
  • 1
    It's easy to mix (as I did) `$car.isReadyForSale` with `car.$isReadyForSale`. The latter one will produce a `Published.Publisher`, which is not what we want. (Actually, @yuanjilee, that's the best answer) – bauerMusic Apr 12 '21 at 07:05
  • This is like what you do with ReactJS. You pass the child view a function that updates the state within the context that it's defined. – Ian Warburton Dec 13 '21 at 04:12
18
  1. You have several options to observe the ObservableObject. If you want to be in sync with the state of the object, it's inevitable to observe the state of the stateful object. From the options, the most commons are:

    • @State
    • @ObservedObject
    • @EnvironmentObject

It is upto you, which one suits your use case.

  1. No. But you need to have an object which can be observed of any change made to that object in any point in time.

In reality, you will have something like this:

class Car: ObservableObject {
    @Published var isReadyForSale = true
}

struct ContentView: View {

    // It's upto you whether you want to have other type 
    // such as @State or @ObservedObject
    @EnvironmentObject var car: Car

    var body: some View {
        SaleButton(isOn: $car.isReadyForSale)
    }

}

struct SaleButton: View {
    @Binding var isOn: Bool
    var body: some View {
        Button(action: {
            self.isOn.toggle()
        }) {
            Text(isOn ? "Off" : "On")
        }
    }
}

If you are ready for the @EnvironmentObject you will initialize your view with:

let contentView = ContentView().environmentObject(Car())
nayem
  • 7,285
  • 1
  • 33
  • 51
  • Thanks, was hoping that the button doesn't have access to the entire `Car` model. I couldn't find a way to create a binding. As Asperi has pointed out, Binding has an initialiser which takes in a getter and setter – user1046037 Dec 10 '19 at 04:22
  • Well @user1046037, I see that. Actually your button won't have that access to the `Car` model unless you deliberately inject it to the `Button` object. If you consider using `@EnvironmentObject` in the container/parent of the button and you don't define an access point to that said `@EnvironmentObject` in your `SaleButton`, the object isn't exposed to the button object at all. Other than that, if you use `@State` or `@ObservedObject` in the container, you won't be able to reference that object from your `SaleButton` without deliberately passing that object around. – nayem Dec 10 '19 at 05:05
  • 1
    But I'm afraid whether the solution you pointed out as @Asperi provided can cope up with your use case or not. Because it's evident that your button won't be able to refresh its view even if it can **mutate** the state of the car object as you aren't helping SwiftUI to remember the state of your car instance. – nayem Dec 10 '19 at 05:19
  • That is correct, however it opens the possibility of not passing the entire model to the button and just passing just a closure that would update. I agree that @ObservedObject would have to be used however I never knew that there was a way to bridge the gap from Observable Object to a Binding (by creating a binding from get / set closure). Your solution works without passing the entire model – user1046037 Dec 10 '19 at 07:54
  • Well, I got your point. Be informed that the creation of a `Binding` doesn't require the object being used in the `get-set` closure to be an `ObservableObject`. Even value type objects can also be used for that reason. – nayem Dec 10 '19 at 08:34
  • I am not sure I follow, are you referring to plain swift properties ? I wanted something that would update my model, not sure how a value type would help. It is nice that environment has a way to bridge to binding variable it but an observed object has to do it via a closure – user1046037 Dec 10 '19 at 08:43
  • 1
    Yes! You can also try that yourself. Just make your `Car` a struct. And for testing purpose you can add a property observer `didSet` in the property of the struct object. – nayem Dec 10 '19 at 08:48
4
struct ContentView: View {
    @EnvironmentObject var car: Car

    var body: some View {
        SaleButton(isOn: self.$car.isReadyForSale)
    }
}

class Car: ObservableObject {
    @Published var isReadyForSale = true
}

struct SaleButton: View {
    @Binding var isOn: Bool

    var body: some View {
        Button(action: {
            self.isOn.toggle()
        }) {
            Text(isOn ? "On" : "Off")
        }
    }
}

Ensure you have the following in your SceneDelegate:

// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
    .environmentObject(Car())
gotnull
  • 26,454
  • 22
  • 137
  • 203
  • Thanks, I totally agree we can do via State, was wondering if there was a way to do without using State (as stated in the question) – user1046037 Dec 10 '19 at 04:15
  • @user1046037 It is doing it without `State`. – gotnull Dec 10 '19 at 04:22
  • It uses environment object, as stated in the question I was hoping not to use `EnvironmentObject` or `ObservedObject` because I didn't want the entire model to be accessible to a simple button. – user1046037 Dec 10 '19 at 04:24
0

In my case i used .constant(viewModel) to pass viewModel to ListView @Binding var viewModel

Example

struct CoursesView: View {
    
    @StateObject var viewModel = CoursesViewModel()
    
    var body: some View {
        
        ZStack {
            ListView(viewModel: .constant(viewModel))
            ProgressView().opacity(viewModel.isShowing)
        }
    }
}

struct ListView: View {
    
    @Binding var viewModel: CoursesViewModel
    
    var body: some View {
        
        List {
            ForEach(viewModel.courses, id: \.id) { course in
                Text(couse.imageUrl)
            }
        }
    }
}