Connecting Observation with Binding in SwiftUI
When I was building Bushel, I ran into an issue where the @Binding object passed to a view would change as the app loaded. This made it difficult to maintain a @Binding object and use the new Observation framework. However I did find a pattern which allowed me to use a @Binding object while keeping the complex functionality needed for an @Observed object.
Dynamic @Binding
Whenever Bushel would open a new Window of a view it would pass an argument with a @Binding value. This is part of the API for a WindowGroup:
init<D, C>(
for type: D.Type,
@ViewBuilder content: @escaping (Binding<D?>) -> C
) where Content == PresentedWindowContent<D, C>, D : Decodable, D : Encodable, D : Hashable, C : View
Notice the the D data must implement Codable as well as Hashable which in most cases should be a simple comparable struct. In our view though we'd like to use the new @Observable macro which requires a class.
Therefore we'll need to use the @Binding value to setup the @Observable object. That is until @Binding properties change. When would that be? In most case when the app is re-opened:
- Open Window with nil
@Bindingobject. - App loads pervious View State with actual Input data.
@Bindingvalue changes with new data.
This means:
- I need to listen to those changes of the passed
@Bindingobject - I need to setup the View to notify the
@Observableclass object of the new changes.
When Binding Changes
For instance, here's an example of a WindowGroup which takes in a @Binding struct of an InputObject.
struct InputObject : Codable, Hashable {
let text : String
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup(for: InputObject.self) { input in
ContentView(input: input)
}
}
}
I can use the @Binding InputObject directly in my SwiftUI View. However, in order to use the Observation framework I need a class object property in my SwiftUI View.
Updating Observed Object
Here is the SwiftUI view we are using:
struct ContentView: View {
@Binding var input : InputObject?
@State var object = OutputObject()
var body: some View {
VStack {
Image(systemName: object.systemName)
.imageScale(.large)
.foregroundStyle(.tint)
Text(object.message)
}
.padding()
}
}
For my @Observable object called OutputObject it will contain all the information needed to render the view. In this case it contains the String message and the SF Symbol system name.
@Observable
class OutputObject {
enum SymbolSystemName : String {
case newItem = "doc.fill.badge.plus"
case existingItem = "hand.thumbsup.fill"
}
var newInputText = ""
var message : String
var symbolSystemName : SymbolSystemName = .newItem
var systemName : String {
return self.symbolSystemName.rawValue
}
}
The next step is to add a way to let the @Observable object know of the InputObject passed top the view. While in the past, I’d use Combine in this case I will use the SwiftUI view as the mediator to let the @Observable object know. Firstly let’s add the InputObject as a property and have it update our properties when it changes:
@Observable
class OutputObject {
...
var input : InputObject? {
didSet {
self.message = self.input?.text ?? Self.newItemMessage
self.symbolSystemName = self.input == nil ? .newItem : .existingItem
}
}
}
Next we’ll use the modifier onChange to send any changes in InputObject to the OutputObject:
struct ContentView: View {
@Binding var input : InputObject?
@State var object = OutputObject()
var body: some View {
VStack {
Image(systemName: object.systemName)
.imageScale(.large)
.foregroundStyle(.tint)
Text(object.message)
}
.onChange(of: self.input, initial: true) { oldValue, newValue in
self.object.input = newValue
}
.padding()
}
}
Now the @Observable object will be updated accordingly when the WindowGroup updates the InputObject inside the @Binding.
Keeping things in Sync
It's important to keep the data in sync between what's passed from the WindowGroup and your @Observable object. This is especially important during the reloading of a previously open window after the application re-launches. In these cases, I use the @Observable object as the source of truth for my SwiftUI View. Here some key points to remember:
- The
@Observableobject and the@Bindingvalue must be separate since they have different requirements: Class and Codable & Hashable Struct respectively. - However I can use the
onChangeto listen to changes in the@Bindingvalue and let the@Observableobject react accordingly.
If you are doing any development using WindowGroup and want to take advantage of the new Observable framework using this pattern will be extremely helpful.