How to define custom SwiftUI environment values
Suppose we had to summarize in one sentence what EnvironmentValues
is.
In that case, we'd probably come up with something like: it's a dictionary of key-value pairs passed down through the view hierarchy.
SwiftUI's official definition is "A collection of environment values propagated through a view hierarchy", but it oversimplifies and hides a bit too much for Five Stars' standards.
SwiftUI defines over 50 public environment values, and, as we've seen in a previous entry, there are many more private.
EnvironmentValues
doesn't stop there: we can also create our own environment values. Let's explore how.
This is the fifth entry of the Five Stars SwiftUI's Environment series. It's recommended to read the first few entries before proceeding: 1, 2, 3, 4.
Creating a new environment value takes two steps:
- define a new
EnvironmentKey
- extend
EnvironmentValues
with our new value
1. Define a new EnvironmentKey
Here's SwiftUI's EnvironmentKey
definition:
public protocol EnvironmentKey {
associatedtype Value
static var defaultValue: Self.Value { get }
}
This key has three main roles:
- it defines the type of the associated environment value (via
associatedtype
) - it helps SwiftUI identify the storage of the environment value
- it defines the environment default value, used when the environment value has not been explicitly set
This first step comes down to define a new struct
conforming to EnvironmentKey
, for example:
struct FSNumberKey: EnvironmentKey {
static let defaultValue: Int = 5
}
Thanks to this definition, we have created a new environment value:
- β¦of type
Int
(as perdefaultValue
type) - β¦whose storage is identified by
FSNumberKey
- β¦with default value
5
EnvironmentValues
is now able to get and set any environment value that uses this FSNumberKey
as its storage.
2. Extend EnvironmentValues
with our new value
If the environment was just a place to store values, we would only need the first step. However, if we take a look at the @Environment
property wrapper definition, we'd see the following:
@propertyWrapper public struct Environment<Value>: DynamicProperty {
public init(_ keyPath: KeyPath<EnvironmentValues, Value>)
public var wrappedValue: Value { get }
}
Similarly, if we take a look at the environment(_:,_:)
view modifier definition, we'd see:
extension View {
public func environment<V>(_ keyPath: WritableKeyPath<EnvironmentValues, V>, _ value: V) -> some View
}
In both cases, we don't use the associated EnvironmentKey
to access or write an environment value. Instead, both @Environment
and the environment(_:,_:)
view modifier ask for a KeyPath
to a EnvironmentValues
property.
This seems like an unnecessary step at first, but it unlocks all sorts of extra functionality.
Continuing with our FSNumberKey
example, the most basic EnvironmentValues
extension that we could make is:
extension EnvironmentValues {
public var fsNumber: Int {
get {
self[FSNumberKey.self]
} set {
self[FSNumberKey.self] = newValue
}
}
}
Thanks to this extension, we can now set and read the FSNumberKey
's value via EnvironmentValues
's new fsNumber
definition.
Setting FSNumberKey
:
VStack {
ViewA()
.environment(\.fsNumber, 1) // ππ»
ViewB()
}
Reading FSNumberKey
:
struct ViewA: View {
@Environment(\.fsNumber) private var fsNumber: Int // ππ»
var body: some View {
Text("\(fsNumber)")
}
}
Unlocking EnvironmentValues
potential
Every time we set/access FSNumberKey
via fsNumber
, we are not directly accessing the underlying EnvironmentValues
value, but we are executing fsNumber
's getter and setter closures. We can add more functionality based on our environment value needs. Let's see some examples.
Non-negative value
Let's say for example that we'd like to prevent FSNumberKey
to ever have a negative value. Instead of adding extra logic in each view that sets this value, we can update our EnvironmentValues
extension with the following:
extension EnvironmentValues {
public var fsNumber: Int {
get { self[FSNumberKey.self] }
set {
self[FSNumberKey.self] = max(0, newValue)
}
}
}
Regardless of the value we set via environment(_:,_:)
view modifier, we will now always have a value equal or greater than zero.
VStack {
ViewA() // ππ» ViewA will see fsNumber with value 0
.environment(\.fsNumber, -5) // ππ» negative value, not allowed! Set to zero instead.
ViewB()
}
Depending on our needs, we can even prevent to write to EnvironmentValues
entirely and use the last valid assigned value instead:
extension EnvironmentValues {
public var fsNumber: Int {
get { self[FSNumberKey.self] }
set {
if newValue >= 0 {
self[FSNumberKey.self] = newValue // ππ» write only if newValue > 0.
}
}
}
}
VStack {
ViewA() // ππ» ViewA will see fsNumber with default value
.environment(\.fsNumber, -5) // ππ» negative value, not allowed! This assignment is ignored.
ViewB()
}
Sharing the storage
Most of the time, we will create a specific EnvironmentKey
for each EnvironmentValues
extension. However this is not enforced in any way. Consider the following example:
struct FSSharedKey: EnvironmentKey {
static let defaultValue: Int = 5
}
extension EnvironmentValues {
public var fsSharedStorage: Int {
get { self[FSSharedKey.self] }
set { self[FSSharedKey.self] = newValue }
}
public var fsSharedStorage2: Int {
get { self[FSSharedKey.self] }
set { self[FSSharedKey.self] = newValue }
}
}
We can now use fsSharedStorage
and fsSharedStorage2
interchangeably:
ViewA() // ππ» both fsSharedStorage and fsSharedStorage will be 6
.environment(\.fsSharedStorage2, 6)
Read-only environment values
Since @Environment
requires only a KeyPath<EnvironmentValues, Value>
and not a WritableKeyPath<EnvironmentValues, Value>
, we could also create read-only environment values.
We have two main ways to implement such read-only behavior:
- the "traditional" way
- the think outside the box way
1. The "traditional" way
The traditional way would be to define a key that then we only read:
struct FSReadOnlyNumberKey: EnvironmentKey {
static let defaultValue: Int = 5
}
extension EnvironmentValues {
public var fsReadOnlyNumber: Int {
get {
return self[FSNumberKey.self]
}
}
}
Since there's no setter in fsReadOnlyNumber
, we can only read this value and the associated \.fsReadOnlyNumber
keypath is not a WritableKeyPath
. Attempting to write into this value would cause a compiler error:
ViewA()
.environment(\.fsReadOnlyValue, 6) // π Key path value type 'WritableKeyPath<EnvironmentValues, Int>' cannot be converted to contextual type 'KeyPath<EnvironmentValues, Int>'
While this works great, as we've seen above, any EnvironmentKey
definition can be shared among various EnvironmentValues
extensions. Besides setting our read-only key as private, there's not much to prevent it from being written by malicious extensions.
2. The think outside the box way
To prevent our environment value from being written elsewhere, we can get rid of its storage altogether. We skip the key definition and write an EnvironmentValues
extension that returns a value directly:
extension EnvironmentValues {
public var fsReadOnlyNumber: Int {
get {
return 5 // ππ» truly read-only environment value
}
}
}
This value will still be accessed via @Environment
but it no longer relies on the EnvironmentValues
storage.
Usage example: an app has a standard horizontal padding that never changes. Instead of using the same magic number throughout the codebase, we could define so via the environment. If we'd like the value to be dynamic in the future, we'd only need to change the
EnvironmentValues
extension implementation.
Conclusions
When it comes to passing down data through the view hierarchy, SwiftUI's environment offers a powerful and "simple" way that has no equivalent in previous imperative UI frameworks such as UIKit and AppKit.
What uses do you have for SwiftUI's Environment? Have you seen anything that stands out?
Please let me know via email or Twitter!
Stay tuned for the final entry on SwiftUI's environment next week!