swiftswiftuiswift6

SwiftUI @Environment with @Binding not working as expected – $myBool causes error but _myBool.wrappedValue works


I’m trying to pass a @Binding value down the SwiftUI view hierarchy using a custom @EnvironmentKey. In my NestedView, I’m using a combination of @Environment and @Binding like this:

@Environment(\.featureEnabledBinding) @Binding var myBool: Bool

I should be able to use this in a Toggle as: Toggle(isOn: $myBool, label: { Text("Toggle") })

However, this gives me a compiler error:

Cannot find '$myBool' in scope

this works just fine: Toggle(isOn: _myBool.wrappedValue, label: { Text("Toggle") })

This is the opposite of what I expected. From what I understand: • _myBool should be the wrapper (Binding) • $myBool should be the projected value (also a Binding)

Here is my code

Environment Setup:

struct FeatureEnabledKey: EnvironmentKey {
    static let defaultValue: Binding<Bool> = .constant(false)
}

extension EnvironmentValues {
    var featureEnabledBinding: Binding<Bool> {
        get { self[FeatureEnabledKey.self] }
        set { self[FeatureEnabledKey.self] = newValue }
    }
}

and Minimal Example

struct ParentView: View {
    @State private var isFeatureEnabled = false

    var body: some View {
        NestedView()
            .environment(\.featureEnabledBinding, $isFeatureEnabled)
    }
}

struct NestedView: View {
    @Environment(\.featureEnabledBinding) @Binding var myBool: Bool

    var body: some View {
        VStack {
            // ❌ This gives an error : Cannot find '$myBool' in scope
            // Toggle(isOn: $myBool, label: { Text("Toggle") })

            // ✅ This works fine
            Toggle("Working Toggle", isOn: _myBool.wrappedValue)
        }
    }
}

Question: Why is $myBool unavailable or invalid in this context? Is this a known SwiftUI/compiler bug with double property wrappers (@Environment @Binding)? or my understandings are not correct

Xcode : 16.3


Solution

  • This is by design. From the property wrappers proposal,

    When multiple property wrappers are applied to a given property, only the outermost property wrapper's projectedValue will be considered.

    So $myBool would have accessed the projected value of the Environment (i.e. _myBool.projectedValue). But since the Environment property wrapper does not have a projectedValue, the $myBool property is not generated.

    A similar case is the built-in editMode, or the now-deprecated presentationMode. I prefer to not write Binding as a property wrapper, and write it as part of the property type instead.

    @Environment(\.featureEnabledBinding) var myBool: Binding<Bool>
    

    Then myBool would be a Binding<Bool>, myBool.wrappedValue would be the Bool.

    Also consider doing what editMode does - change the environment value's type to Binding<Bool>?, and change the default value to nil. This makes it obvious if you forgot to set the environment value.