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:
- Offer a modifier on the view itself that returns an altered version of type
Self
- Extend
View
with a modifier that communicates the change down through the environment, returningsome 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.