This one is a bit confusing so I made a model so its easier to discuss:
@Observable
class Nest {
var mutable = 0
}
@Observable
class Model {
var mutable = 0
let immutable = 0
var mutableNest = Nest()
let immutableNest = Nest()
}
I found that SwiftUI can observe changes to all properties here, and Swift allows you to modify immutableNest.mutable
, but you many not create a binding to immutableNest.mutable
.
Heres some SwiftUI code
struct ContentView: View {
@State var obj = Model()
var body: some View {
VStack {
Button { obj.mutable += 1 } label: { Text("Increment obj.mutable (\(obj.mutable))") }
NumberField(i: $obj.mutable)
/*
Cannot bind or modify obj.immutable. This makes sense to me.
Button {
// NO Left side of mutating operator isn't mutable: 'immutable' is a 'let' constant
//obj.immutable += 1
} label: {
Text("Increment obj.immutable (\(obj.immutable))")
}
// NO Cannot assign to property: 'immutable' is a 'let' constant
//NumberField(i: $obj.immutable)
*/
Button { obj.mutableNest.mutable += 1 } label: { Text("Increment obj.mutableNest.mutable (\(obj.mutableNest.mutable))") }
NumberField(i: $obj.mutableNest.mutable)
Button { obj.immutableNest.mutable += 1 } label: { Text("Increment obj.immutableNest.mutable (\(obj.immutableNest.mutable))") }
// NO! Cannot assign to property: 'immutableNest' is a 'let' constant
//NumberField(i: $obj.immutableNest.mutable)
}
}
}
As you can see, writing a binding $obj.immutableNest.mutable
gives the error Cannot assign to property: 'immutableNest' is a 'let' constant.
I know I could switch to var, but using a let
in my model class could very well make sense to the model. Like if I have a GlobalState
and a SettingsState
object. I dont want GlobalState
to change its reference to its setting state object but I may want to bind to a property in that nested state.
I think I have some general confusion on the swift rules when a reference to a reference type is const but the properties inside it are mutable.
So, what is the most elegant way to deal with this? I had a few ideas:
@Bindable
help here?And in case you want to run it yourself, here is the NumberField view
struct NumberField: View {
@Binding var i: Int
@State private var text: String = ""
var body: some View {
TextField("Edit", text: $text)
.keyboardType(.numberPad)
.onReceive(Just(text)) { newValue in
let filtered = newValue.filter { "0123456789".contains($0) }
if filtered != newValue {
self.text = filtered
self.i = Int(filtered) ?? 0
}
}
.onReceive(Just(i)) { newValue in
self.text = "\(i)"
}
.onAppear {
text = "\(i)"
}
}
}
This is due to how @dynamicMemberLookup
is desugared. When you write
$obj.immutableNest.mutable
You are actually repeatedly calling the dynamicMember:
subscript of Binding
. This desugars to:
$obj[dynamicMember: \.immutableNest][dynamicMember: \.mutable]
The subscript takes a WritableKeyPath
, but \.immutableNest
is a let
. If @dynamicMemberLookup
had been designed to not break up the key path components, i.e. if it desugared to
$obj[dynamicMember: \.immutableNest.mutable]
then this would have compiled, because \.immutableNest.mutable
is a writable key path. So one way to fix this is simply write $obj[dynamicMember: \.immutableNest.mutable]
.
Another way is to use @Bindable
.
@Bindable var nest = obj.immutableNest
NumberField(i: $nest.mutable)