magnuskahr

writing code

Introducing the EnvironmentBacked property wrapper

When designing the API for content-specific views in SwiftUI, I strive to have my initializers as simple as possible. Take the built-in Text view. Its initializers focus on its content: "What text should I show?", and not: "How should I show it?". All changes of how a Text view renders its content, are done through modifiers like Text("Hello World").bold().

The Text view has a set of these modifiers all returning Text, meaning that the "hello world" example still has the type of Text. This can be very important when we design views that take a Text as input. However, SwiftUI also offers all of these modifiers where they return some View, meaning that the changes will go through the environment.

I think this is a great pattern for flexible views:

  1. Offer a modifier on the view itself that returns an altered version of type Self
  2. Extend View with a modifier that communicates the change down through the environment, returning some View

Doing so, helps us to build an API that is concise and flexible: Initializers only care about what to display (for content-specific views), and modifiers control how to display.

Use case: Avatar accessories

Say we have an Avatar view, that shows an image with a border. Now we wanna offer accessories on this avatar, like a notification badge.

If we were to write this logic to follow the API pattern above, we would have something along the lines of:

struct Avatar: View {
   
  @Environment(\.avatarAccessory) private var envAvaAcc
  private var _avaAcc: AvatarAccessory?
   
  private var avaAcc: AvatarAccessory? {
    // Favor a local accessory over an environmental
    _avaAcc ?? envAvaAcc
  }
   
  let image: Image
   
  var body: some View { /* ... */ }
}

Then Avatar would have a method to set the local accessory:

extension Avatar {
  func avatarAccessory(_ accessory: AvatarAccessory) -> Self {
    var copy = self
    copy._acaAcc = accessory
    return copy
  }
}

and an extension on View to communicate down through the environment:

extension View {
  func avatarAccessory(_ accessory: AvatarAccessory) -> some View {
    environment(\.avatarAccessory, accessory)
  }
}

The important part lies in the first part of the code: how the correct accessory is resolved by first looking at a local optional value, and if it's nil then resorting to an environmentally provided value. While this part is boring boilerplate code, along with the two methods as well, we can however make a clever property wrapper that takes care of the resolvent for us.

Introducing the EnvironmentBacked property wrapper

As we built out our views, we may end up having many resolvents in a single view, and it fills a lot to have three properties just for a single value. To solve this problem, and generalize the use, let me introduce the EnvironmentBacked property wrapper:

@propertyWrapper
public struct EnvironmentBacked<E>: DynamicProperty {

  @Environment private var environmentValue: E
  private var internalValue: E?

  public init(wrappedValue: E? = nil, _ keyPath: KeyPath<EnvironmentValues, E>) {
    self._environmentValue = Environment(keyPath)
    self.internalValue = wrappedValue
  }

  public var wrappedValue: E {
    get { internalValue ?? environmentValue }
    set { internalValue = newValue }
  }
}

It takes the type of the given key path to the environment, and can therefore also be optional. With this property, we can now change the avatar view:

struct Avatar: View {
   
  @EnvironmentBacked(\.avatarAccessory) private var avaAcc
  let image: Image
   
  var body: some View { /* ... */ }
}

And we see that the view properties have become a lot simpler to deal with.

Conclusion

Splitting initializers and modifiers up to handle respectively content and rendering, allows our initializers to focus on their content because let's face it: SwiftUI views can have a lot of different initializers.

Enforcing this pattern, and creating internal and external modifiers, is done a lot simpler using the @EnvironmentBacked property wrapper. It enables you to create flexible views with a rich API as with the built-in Text.