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:

  1. define a new EnvironmentKey
  2. 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:

  1. it defines the type of the associated environment value (via associatedtype)
  2. it helps SwiftUI identify the storage of the environment value
  3. 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:

  1. …of type Int (as per defaultValue type)
  2. …whose storage is identified by FSNumberKey
  3. …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:

  1. the "traditional" way
  2. 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!

β­‘β­‘β­‘β­‘β­‘

Further Reading

Explore SwiftUI

Browse all