swiftswiftui

Why cant you create a binding to mutable state of an @Observable object with a mutable reference?


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:

  1. Can @Bindable help here?
  2. Perhaps this indicates a poorly designed model object? Perhaps the nested object shouldn't be visible externally, or there shouldn't be a nested object anyway?
  3. Perhaps I should create a custom binding

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)"
            }
        
    }
}

Solution

  • 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)