Create custom environment types in SwiftUI with less code
In this post, I’ll describe how I abused the Swift type system to come up with an approach to let us create custom SwiftUI environment values with less code than otherwise needed.
Background
Consider that you have a MyView
view, that has a MyViewStyle
that can be used to style it:
struct MyView {
let style: MyViewStyle
var body: some View {
style.color
}
}
struct MyViewStyle {
var color: Color
}
One way to apply this style is to inject it with the initializer, with a default fallback value:
struct MyView {
init(style: MyViewStyle = .standard) {
self.style = style
}
var style: MyViewStyle
var body: some View {
style.color
}
}
struct MyViewStyle {
init(color: Color = .red) {
self.color = color
}
var color: Color
static var standard = Self()
}
However, init injection become complicated in more complex view hierarchies, where you may find yourself passing around values everywhere to get things to work.
Instead of init injection, SwiftUI provides a convenient way of injecting values into the view environment, after which views can use @Environment
to access the injected value.
SwiftUI uses this convention extensively, for instance with the .buttonStyle
view modifier, as well as many, many other styles and configurations that exist in the framework.
Environment injection is MUCH more flexible than injecting values into the initializer, since you can inject environment values into any part of the view hierarchy.
It’s also nice to remove parameters from the initializer, since more complex views, as well as generic ones, can end up with complicated permutations if you have many parameters.
How to define an environment value - the complicated way
To make it possible to inject a custom type into the view environment, you must extend the native EnvironmentValues
type with a property that can get and set a value of that type.
To do this, you must also define a type that conforms to the EnvironmentKey
protocol, and returns a defaultValue
of your type.
Finally, it’s nice to also provide a view modifier with the same name as the value you want to inject, much like ButtonStyle
has a matching .buttonStyle
modifier.
Apple provides boilerplate code for making this happen, here rewritten for MyViewStyle
:
public extension MyStyle {
static var standard = Self()
}
private extension MyStyle {
struct Key: EnvironmentKey {
static var defaultValue: MyStyle = .standard
}
}
public extension EnvironmentValues {
var myStyle: MyStyle {
get { self[MyStyle.Key.self] }
set { self[MyStyle.Key.self] = newValue }
}
}
public extension View {
func myStyle(_ style: MyStyle) -> some View {
environment(\.myStyle, style)
}
}
It’s not much code, but imagine having many custom types, and for each you’d have to:
- Provide a default value.
- Define a
Key
type that returns the default value. - Create an
EnvironmentValues
property, using the complicated subscript syntax. - Provide a view extension.
I’d like this to be a little simpler, and have spent some time playing around with plain Swift and protocols to come up with a more streamlined approach. Let’s take a look.
How to define an environment value - the easy way
Instead of the boilerplate code above, I have created and published an open-source library called EnvironmentKit, that lets you achieve the same result with this code:
struct MyStyle: EnvironmentValue {
static var keyPath: EnvironmentKeyPath { \.myStyle }
}
extension EnvironmentValues {
var myStyle: MyStyle {
get { get() } set { set(newValue) }
}
}
extension View {
func myStyle(_ style: MyStyle) -> some View {
environment(style)
}
}
While you don’t save that many lines of code, you avoid the repetitions of having to refer to the same type many times, the correct keypath, etc. All in all, I find it cleaner.
Everything is powever by having your type implementing the EnvironmentValue
protocol, which provides EnvironmentKit with all it needs to resolve keys, keypaths, etc.
Let’s see how it’s implemented.
Creating EnvironmentKit
I wanted a core protocol to power the library, and since SwiftUI has an EnvironmentValues
type, I decided to call it EnvironmentValue
(this naming will surely punish me in the future):
public protocol EnvironmentValue {}
To be able to resolve things automatically, the protocol will require its implementing types to provide a parameterless initializer, or default values for all properties:
public protocol EnvironmentValue {
init()
}
With this initializer, the protocol can now provide a default value for all implementing types:
public extension EnvironmentValue {
static var defaultValue: Self { .init() }
}
We can now automatically provide a key type for every value type, using the default value:
public protocol EnvironmentValue {
init()
typealias EnvironmentKey = EnvironmentValueKey<Self>
}
public struct EnvironmentValueKey<T: EnvironmentValue>: EnvironmentKey {
public static var defaultValue: T { T.defaultValue }
}
We can now extend the native EnvironmentValues
type with a getter and a setter that uses this key information to get and set values for any EnvironmentValue
:
public extension EnvironmentValues {
func get<T: EnvironmentValue>() -> T {
self[T.EnvironmentKey.self]
}
mutating func set<T: EnvironmentValue>(_ newValue: T) {
self[T.EnvironmentKey.self] = newValue
}
}
This lets us avoid having to use the subscript syntax for every new value type. Instead, we can define custom environment value properties like this:
private extension EnvironmentValues {
var myViewStyle: MyViewStyle {
get { get() } set { set(newValue) }
}
}
To avoid having to specify a keypath when using an environment value type, we can force each type to specify its keypath:
public protocol EnvironmentValue {
init()
static var keyPath: EnvironmentPath { get }
typealias EnvironmentKey = EnvironmentValueKey<Self>
typealias EnvironmentPath = WritableKeyPath<EnvironmentValues, Self>
}
This means that all a type has to do is to provide a default initializer or default values for each property, as well as a key path. This is how the MyViewStyle
from above could look:
private struct MyViewStyle: EnvironmentValue {
var color: Color = .blue
static var keyPath: EnvironmentPath { \.myViewStyle }
}
EnvironmentKit has a custom .environment
view modifier that just needs a value, and then uses the type information to figure out which key path to use:
public extension View {
func environment<T: EnvironmentValue>(
_ value: T
) -> some View {
environment(T.keyPath, value)
}
}
This means that we don’t have to repeat the key path information when providing a custom myViewStyle
modifier. Instead, we can just do this:
private extension View {
func myViewStyle(_ style: MyViewStyle) -> some View {
environment(style)
}
}
We can now apply .myViewStyle(...)
to any view, then use @Environment(\.myViewStyle)
to access the injected value in any view. If no value is injected, a default value is returned.
Future work
Although the end result lets you create custom environment types with less code, I hoped to be able to use the EnvironmentValue
to do even more automatically.
My initial idea was for the keyPath
to be automatically resolved by EnvironmentValues
, by using a generic function that could be use instead of an explicit key path property.
However, Swift seems to require an actual property to be able to use it as a keypath in the .environment
modifier. If we could work around this, we’d need even less code.
The dream would be for the type to just implement the EnvironmentValue
protocol, and for EnvironmentKit to take care of the rest.
Conclusion
The EnvironmentValue
approach lets us define custom environment types with a lot less code. If you think this approach looks interesting, make sure to give EnvironmentKit a try.
Discussions & More
Please share any ideas, feedback or comments you may have in the Disqus section below, or by replying to this tweet or this toot.
If you found this text interesting, make sure to follow me on Twitter and Mastodon for more content like this, and to be notified when new content is published.
If you like & want to support my work, please consider sponsoring me on GitHub Sponsors.