Nested @State doesn't work with SwiftUI

When using SwiftUI it is common to pass a binding to a subview. Usually, a change to that binding's value will trigger a change to the UI, and often we want that change to be animated. For this reason, we've been given this tool:

Subview(binding: $myValue.animation())

This will apply an animation to any change to the binding. While this is a nice feature, I have found that I end up with code duplication when I use it - I often pass the same binding to several subviews, and I apply the .animation() method to all of them. When I decide that I want to use .animation(.spring()) instead, I have to change it everywhere or I end up with inconsistent animations.

So far I've been using this sub-par solution:

@State
private var isSheetDisplayed = false

private var isSheetDisplayed_modifiedBinding: Binding<Bool> {
    $isSheetDisplayed.animation()
}

and then passing isSheetDisplayed_modifiedBinding to my subviews instead of $isSheetDisplayed. The advantage of this is of course that if I decide to change the animation (or any other modifiers to the binding) I can do it in one place... theoretically. The downsides are numerous. Perhaps most importantly, it doesn't actually keep me much safer - I still have to remember to use the horribly unergonomic isSheetDisplayed_modifiedBinding as opposed to the immensely preferable syntax $isSheetDisplayed, and if I forget and accidentally pass $isSheetDisplayed to one of my subviews then again I end up with inconsistent animations.

I have a much better solution that seems to work perfectly in all ways except one - it doesn't work... here's how my solution would look in action:

@State.ModifiedBinding({
    $0.animation()
})
private var isSheetDisplayed = false

This new property wrapper is defined like this:

extension State where Value == Never {
    @propertyWrapper
    struct ModifiedBinding <Value> {
    
        @State<Value>
        private var state: Value
    
        private let bindingModifier: (Binding<Value>)->Binding<Value>
    
        init (wrappedValue: Value, _ bindingModifier: @escaping (Binding<Value>)->Binding<Value>) {
            self._state = State<Value>(initialValue: wrappedValue)
            self.bindingModifier = bindingModifier
        }
    
        var wrappedValue: Value {
            get { state }
            set { state = newValue }
        }
    
        var projectedValue: Binding<Value> {
            bindingModifier($state)
        }
    }
}

This solution meets all my needs - it allows me to bake certain behavior into the @State itself so that I can still use the $isSheetDisplayed syntax - there is no possibility of programmer error here. Sadly, values which are written to the binding that this property wrapper produces do not make it all the way to the source of truth. The binding's setter is indeed invoked with the proper value, but the true underlying @State is apparently not ultimately changed, because the UI doesn't update and when I query the state directly it always returns the initial value.

The real underlying mystery here is, how exactly does SwiftUI figure out how to map State values (which are indeed value types) to the underlying persistently referenced memory which they manage? Clearly, my binding here is writing its values to some State - it just appears that the State which it is writing to has not been linked up to the underlying persistent value which SwiftUI is retaining.

UPDATE:

The fully functional code, thanks to Adrian Meister's insight, is this:

extension State where Value == Never {
    @propertyWrapper
    struct ModifiedBinding <Value>: DynamicProperty {
    
        @State<Value>
        private var state: Value
    
        private let bindingModifier: (Binding<Value>)->Binding<Value>
    
        init (wrappedValue: Value, _ bindingModifier: @escaping (Binding<Value>)->Binding<Value>) {
            self._state = State<Value>(initialValue: wrappedValue)
            self.bindingModifier = bindingModifier
        }
    
        var wrappedValue: Value {
            get { state }
            set { state = newValue }
        }
    
        var projectedValue: Binding<Value> {
            bindingModifier($state)
        }
    }
}

Try to conform your property wrapper to this protocol and check if it gets recognized this time: https://developer.apple.com/documentation/swiftui/dynamicproperty

Thank you! Yes, it worked perfectly. What a gem this forum is.

Perhaps I'm misunderstanding how it all fits together, but it seems like this must rely on reflection to allow SwiftUI to scan my views for any properties which conform to DynamicProperty - do you think that's accurate?

1 Like

Possibly, I don't know for sure. They might iterate over stored properties and look for known property wrapper types or types that conform to DynamicProperty protocol.

@luca_bernardi knows, but I no idea if he's going to tell us. :smiley:


For fun you could find out more about SwiftUI types by dumping them during the body call.

var body: some View {
  dump(_someState)
  return MyView(...)
}
Terms of Service

Privacy Policy

Cookie Policy