Adding an @EnvironmentBinding PropertyWrapper


Sometimes we need to pass a Binding as an environment value. A common example of this is the binding that is used to control navigation, many nested controls and actions within your App will need to both read and write to this state to alter what is displayed.


Currently, we can pass Bindings into the environment however when we read them out using @Environment(\.keyPath) we end up with the value wrapped in the Binding<Value> type. This has some issues: Firstly if the value that this Binding points to changes the view may not re-render, Secondly to read/write to the value we need to use the unusual pattern myValue.wrappedValue = ...


I would like to propose an alternative solution that lets us create Views using the same syntax as we would for a regular @Binding property.


Here is an example of how you will be able to use the proposed @EnvironmentBinding(ToggleValueKey.self) property wrapper.


struct ToggleView: View {
  @EnvironmentBinding(ToggleValueKey.self)
  var enabled
   
  var body: some View {
    HStack {
      Toggle(isOn: $enabled) {
        Text("Toggle")
      }
       
      Button("Tap me") {
        enabled = true
      }
    }
  }
}

Notice that we can pass it on to other controls using the expected $ prefix, and we can both read and write the value directly within the body.


Declaring an Environment Value, exposing its BoundValue type.


To begin with, we declare a new protocol that extends the regular EnvironmentKey, we do this so that we can extract the BoundValue type that is contained within the Binding<BoundValue>.


protocol EnvironmentBindingKey: EnvironmentKey where Value == Binding<BoundValue> {
  associatedtype BoundValue
   
  static var path: KeyPath<EnvironmentValues, Value> { get }
  static var defaultBoundValue: BoundValue { get }
}


For connivance we can also declare an extension that provides the defaultValue implementation required by EnvironmentKey.


extension EnvironmentBindingKey {
  static var defaultValue: Value {
    .constant(self.defaultBoundValue)
  }
}


Conforming to this is similar to EnvironmentKey.


struct ToggleValueKey: EnvironmentBindingKey {
  static var path: KeyPath<EnvironmentValues, Binding<Bool>> {
    \.toggle
  }
   
  static var defaultBoundValue: Bool {
    false
  }
}

extension EnvironmentValues {
  var toggle: Binding<Bool> {
    get {
      return self[ToggleValueKey.self]
    }
    set {
      self[ToggleValueKey.self] = newValue
    }
  }
}


We define the ToggleValueKey providing a defaultBoundValue and the KeyPath to it's usage in EnvironmentValues. We also add the corresponding extension to EnvironmentValues for this KeyPath.


Creating the @EnvironmentBinding property wrapper


This property wrapper is a little bit more involved than some. But at its core, it extracts the Environment value from the @Environment and assigns that to a @Binding.


@propertyWrapper
struct EnvironmentBinding<Key: EnvironmentBindingKey>: DynamicProperty {

  init(_ key: Key.Type) {
    self._value = Environment(key.path)
    self._wrappedValue = Key.defaultValue
  }
   
  @Environment
  private var value: Binding<Key.BoundValue>
   
  @Binding
  var wrappedValue: Key.BoundValue
   
  var projectedValue: Binding<Key.BoundValue> {
    self.value
  }
   
  mutating func update() {
    self._value.update()
    self._wrappedValue = value
    self._wrappedValue.update()
  }
}


Important here is the DynamicProperty conformance that provides the update() method. This is called by SwiftUI before the view body is evaluated, at this point we can take the opportunity to update the @Binding instance from the current value in the environment.