Custom SwiftUI Environment Values Cheatsheet

In How to define custom SwiftUI environment values we covered all the theory behind creating custom environment values. In this last entry of the Five Stars SwiftUI Environment series, let's have a quick tour of the most common custom value types we might define.

This is the sixth and last entry of the Five Stars SwiftUI Environment series. It's recommended to read the first few entries before proceeding: 1, 2, 3, 4, 5.

Struct, Enums, and primitives

SwiftUI environment values have been built specifically for these types, we've seen multiple examples of declaring a primitive environment value:

struct FSNumberKey: EnvironmentKey {
  static let defaultValue: Int = 5
}

extension EnvironmentValues {
  public var fsNumber: Int {
    get { self[FSNumberKey.self] } 
    set { self[FSNumberKey.self] = newValue }
  }
}

Replace Int with any other primitive, struct, or enum definition, and it will work the same way.

// Read:

struct ContentView: View {
  @Environment(\.fsNumber) var number: Int // πŸ‘ˆπŸ»

  var body: some View {
    Text("\(number)") // πŸ‘ˆπŸ» 
  }
}

// Set:

someView()
  .environment(\.fsNumber, 10)

about 90% of the built-in environment values fall under this category.

Bindings

The @Environment property wrapper gives us a read-only value, thus it makes sense to think about environment values as something set by a parent and read by a child.

However, we can use bindings to allow children to modify environment values, with the new changes bubbling up to their ancestors.

Take the following definition:

struct FSBoolBindingKey: EnvironmentKey {
  static var defaultValue: Binding<Bool> = .constant(false)
}

extension EnvironmentValues {
  var fsBoolBinding: Binding<Bool> {
    get { self[FSBoolBindingKey.self] }
    set { self[FSBoolBindingKey.self] = newValue }
  }
}

This is very similar to any other environment value type, but it lets child views both read and modify the value:

struct ContentView: View {
  @State var myBool = false

  var body: some View {
    VStack {
      Text(myBool ? "true" : "false")
      NestedView()
        .environment(\.myBoolBinding, $myBool) // πŸ‘ˆπŸ» we inject myBool binding into the environment
    }
  }
}

struct NestedView: View {
  @Environment(\.myBoolBinding) @Binding var myBool: Bool // πŸ‘ˆπŸ» we read the binding from the environment

  var body: some View {
    Toggle(isOn: _myBool.wrappedValue, label: EmptyView.init) // πŸ‘ˆπŸ» we read/write the environment value!
  }
}

Here's another we way we could have defined the NestedView:

struct NestedView: View {
  @Environment(\.myBoolBinding) var myBool: Binding<Bool> // πŸ‘ˆπŸ» different type

  var body: some View {
    Toggle(isOn: myBool, label: EmptyView.init) // πŸ‘ˆπŸ» different isOn parameter
  }
}

This is equivalent to passing a binding as a parameter of a view, but via environment.

SwiftUI built-in binding environment values: presentationMode.

Optional bindings

In the previous FSBoolBindingKey definition, we've set a default value of .constant(false), which works great as long as we remember to set the binding in the environment before it's used:

struct ContentView: View {
  @State var myBool = false

  var body: some View {
    ...
    NestedView()
      .environment(\.myBoolBinding, $myBool) // πŸ‘ˆπŸ» 
    ...
  }
}

If we forget to set this value into the environment, NestedView (or any other view) won't be able to modify the myBoolBinding environment value.

Depending on our use case, this might be what we want. Alternatively, similarly to what we've seen in How to add optional @Bindings to SwiftUI views, we could define FSBoolBindingKey and fsBoolBinding with an associated optional binding:

struct FSBoolBindingKey: EnvironmentKey {
  static var defaultValue: Binding<Bool>?
}

extension EnvironmentValues {
  var fsBoolBinding: Binding<Bool>? {
    get { self[FSBoolBindingKey.self] }
    set { self[FSBoolBindingKey.self] = newValue }
  }
}

At this point, views using this value will have to deal with a binding that might or might not be there. In the following example, NestedView will use the environment value when present and, alternatively, will use a private state (that doesn't affect the environment):

struct NestedView: View {
  @Environment(\.fsBoolBinding) var myBool
  @State private var privateBool = false // πŸ‘ˆπŸ» 

  var body: some View {
    Toggle(isOn: myBool ?? $privateBool, label: EmptyView.init) // πŸ‘ˆπŸ» 
  }
}

SwiftUI built-in optional binding environment values: editMode.

Actions

This year we've seen the introduction of a new environment-actions pattern in SwiftUI:
instead of views directly accepting closures to trigger in specific scenarios, it's now common to set closures into the environment, which are then picked up and triggered by relevant/associated views.

An example is the new onSubmit modifier:

Form {
  TextField(...)
  TextField(...)
}
.onSubmit {
  print("Form submitted")
}
// πŸ‘†πŸ» this closure is set into the environment and picked up by TextField, SecureField, and TextEditor.

To define a new action, we first define a struct accepting a closure (depending on our case, this closure may accept/return values):

struct FSAction {
  var action: () -> Void

  init(action: @escaping () -> Void = { }) {
    self.action = action
  }
}

We might have extra logic in this struct that we probably don't want to expose to other developers. Hence we set our action as private and, instead, we make the struct type callable via Swift's special callAsFunction method:

struct FSAction {
  private var action: () -> Void // πŸ‘ˆπŸ» private

  func callAsFunction() { // πŸ‘ˆπŸ» 
    action()
  }

  init(action: @escaping () -> Void = { }) {
    self.action = action
  }
}

Next, we create the usual key and extend environment values:

struct FSActionKey: EnvironmentKey {
  static var defaultValue: FSAction = FSAction()
}

extension EnvironmentValues {
  var fsAction: FSAction {
    get { self[FSActionKey.self] }
    set { self[FSActionKey.self] = newValue }
  }
}

...and now we're ready to use this new action:

// Read:

struct ContentView: View {
  @Environment(\.fsAction) var action: FSAction // πŸ‘ˆπŸ»

  var body: some View {
    Button("Tap me") { action() } // πŸ‘ˆπŸ» 
  }
}

// Set:

someView()
  .environment(\.fsAction, FSAction { // πŸ‘ˆπŸ» 
    ...
  })

Thanks to FSAction's callAsFunction we don't need to reach for the FSAction.action property (and we can't, as it's private). Instead, we call the function directly on the FSAction instance. This helps hide any implementation detail of our FSAction struct.

SwiftUI built-in actions: dismissSearch (DismissSearchAction), openURL (OpenURLAction), refresh (RefreshAction). resetFocus (ResetFocusAction), dismiss (DismissAction).

Closures

The action pattern that we just introduced has an associated struct type that really is a wrapper for a closure. This was true for our simple case, but, in reality, our FSAction struct may contain implementation details that are just not exposed.

If we want our environment value to be just a closure and nothing else, we can skip the action struct definition, and use a closure directly:

struct ClosureKey: EnvironmentKey {
  static let defaultValue: () -> Void = { }
}

extension EnvironmentValues {
  public var fsAction: () -> Void {
    get { self[ClosureKey.self] }
    set { self[ClosureKey.self] = newValue }
  }
}

Which we can then use this way:

// Read:

struct ContentView: View {
  @Environment(\.fsAction) var action: () -> Void // πŸ‘ˆπŸ»

  var body: some View {
    Button("Tap me") { action() } // πŸ‘ˆπŸ» 
  }
}

// Set:

someView()
  .environment(\.fsAction) { // πŸ‘ˆπŸ» 
    ...
  }

Unlike SwiftUI views, which must be value types, environment values can also be reference types.

A kind reminder that Swift closures are reference types.

Classes

Speaking of reference types, we can also define environment values with an associated class type.

There are important implications when using a class rather than a struct:

  • we can alter a class instance, and the exact change will be reflected anywhere else that same instance is referenced in the view hierarchy.
  • views do not observe changes within classes defined in EnvironmentValues, regardless of whether the class is marked ObservableObject. If we're trying to do something similar to this, we should use environment objects instead.

Example:

public class FSClass {
  var x: Int

  init(x: Int = 5) {
    self.x = x
  }
}

private struct FSClassKey: EnvironmentKey {
  static let defaultValue = FSClass()
}

extension EnvironmentValues {
  public var fsClass: FSClass {
    get { self[FSClassKey.self] }
    set { self[FSClassKey.self] = newValue }
  }
}

Which we can then use like any other environment value:

// Read:

struct ContentView: View {
  @Environment(\.fsClass) private var fsClass // πŸ‘ˆπŸ»

  var body: some View {
    VStack {
      Text("\(fsClass.x)") // πŸ‘ˆπŸ»

      Button("change") {
        fsClass.x = Int.random(in: 1...99) 
        // πŸ‘†πŸ» fsClass is a class, we can modify its properties
      }
    }
  }
}

// Set:

someView()
  .environment(\.fsClass, FSClass(x: 1))

There are very few reasons why we'd ever define a class-type environment value, but it's good to know that it's supported.

SwiftUI built-in class environment values: managedObjectContext (NSManagedObjectContext) and undoManager (UndoManager).

Conclusions

We've now completed the Five Stars SwiftUI environment series, thank you for reading, and I hope you've found these articles helpful!

What else would you like me to cover next? Let me know via email or Twitter!

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

Further Reading

Explore SwiftUI

Browse all