Environment Objects and SwiftUI Styles

SwiftUI
20 July 2021

SwiftUI's environment and styles are two pillars of Apple's declarative framework. However, when SwiftUI first launched, using them together resulted in a guaranteed app crash.

More specifically, the crash happened when we used @EnvironmentObject inside our styles definition: when is it safe to use them together? Let's find out.

For the impatients, the results are at the end of the article.

An example

Meet FSStyle, a button style expecting an environment object (FSEnvironmentObject):

class FSEnvironmentObject: ObservableObject {
  @Published var title = "tap me"
}

struct FSStyle: ButtonStyle {
  @EnvironmentObject var object: FSEnvironmentObject

  func makeBody(configuration: Configuration) -> some View {
    Button(object.title) { }
  }
}

For an introduction to Buttons styles, see Exploring SwiftUI's Button styles and Meet the new Button styling.

From this definition, we'd expect things to work as long as we inject the environment object at some point before applying the style. For example:

struct ContentView: View {
  @StateObject var object = FSEnvironmentObject()

  var body: some View {
    Button("tap me") {
    }
    .buttonStyle(FSStyle())
    .environmentObject(object)
  }
}

..and yet, if we ran this code in any iOS version before 14.5, it reliably crashed 100% of the times, with Fatal error: No ObservableObject of type FSEnvironmentObject found..

The crash occurs as soon as the environment object is used within the makeBody(configuration:) method, both the object definition and makeBody(configuration:) implementation don't matter.

The main two workarounds are either to make the style conform to DynamicProperty (many thanks to Lin Qing Mo for letting me know!), or to return a view in the makeBody(configuration:) method, and have that view read the environment object.

Now that we've seen what the issue is, let's explore in which iOS versions and styles this crash occurs.

The test setup

We want to figure out in which iOS versions is safe to use all the possible styles (not just button styles). We can create a small test app and run it throughout all iOS versions supporting SwiftUI and report back.

Continuing with the ButtonStyle example, here's the complete app:

import UIKit
import SwiftUI

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { }

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  var object: FSEnvironmentObject = FSEnvironmentObject()

  func scene(
    _ scene: UIScene, 
    willConnectTo session: UISceneSession, 
    options connectionOptions: UIScene.ConnectionOptions
  ) {
    if let windowScene = scene as? UIWindowScene {
      let window = UIWindow(windowScene: windowScene)
      let contentView = ContentView().environmentObject(object)
      window.rootViewController = UIHostingController(rootView: contentView)
      self.window = window
      window.makeKeyAndVisible()
    }
  }
}

struct ContentView: View {
  var body: some View {
    Button("tap me") {
    }
    .buttonStyle(FSStyle())
  }
}

struct FSStyle: ButtonStyle {
  @EnvironmentObject var object: FSEnvironmentObject

  func makeBody(configuration: Configuration) -> some View {
    Button(object.title) { }
  }
}

class FSEnvironmentObject: ObservableObject {
  @Published var title = "tap me"
}

The app consists of one screen, which contains our test component/style.

A few points:

  • we're using UIKit's lifecycle because we want to run the tests with iOS 13 as well
  • we're don't use @StateObject for the environment object because that property wrapper is iOS 14+ only
  • the only difference between testing ButtonStyle and other styles is in the FSStyle and ContentView body definition, everything else stays the same

CI/CD Setup

We will be testing twelve iOS versions, from iOS 13.0 to iOS 14.5, and all eight styles that support customization.

Testing each combination manually would be quite a lot of work, instead, we can let a CI/CD provider do all the heavy lifting for us. Any CI/CD setup will do, here's how the various Xcode/iOS versions where distributed for this study:

  • macOS 10.14
    • iOS 13.0, Xcode 11.0
    • iOS 13.1, Xcode 11.1
    • iOS 13.2, Xcode 11.2
  • macOS 10.15
    • iOS 13.3, Xcode 11.3.1
    • iOS 13.4, Xcode 11.4.1
    • iOS 13.5, Xcode 11.5
    • iOS 13.6, Xcode 11.6
    • iOS 13.7, Xcode 11.7
    • iOS 14.0, Xcode 12.0.1
    • iOS 14.1, Xcode 12.1
    • iOS 14.2, Xcode 12.2
    • iOS 14.3, Xcode 12.3
  • macOS 11.4:
    • iOS 14.4, Xcode 12.4
    • iOS 14.5, Xcode 12.5

Results

As the test app only has one screen that shows immediately the tested component, all it's needed to pass the test is launching the app and not crash right away. Here is the outcome:

Style 👇🏻 / iOS 👉🏻13.013.113.213.313.413.513.613.714.014.114.214.314.414.5
ButtonStyle💥💥💥💥💥💥💥💥💥💥💥💥💥
GroupBoxStyle*
LabelStyle*
MenuStyle*
PrimitiveButtonStyle💥💥💥💥💥💥💥💥
ProgressViewStyle*
TextFieldStyle💥💥💥💥💥💥💥💥
ToggleStyle💥💥💥💥💥💥💥💥

💥 = crash, ✅ = pass. *Style available from iOS 14.

To recap:

  • all styles but ButtonStyle support @EnvironmentObject from iOS 14.0
  • from iOS 14.5, all styles including ButtonStyle support @EnvironmentObject

Conclusions

The reason why the styles + @EnvironmentObject combo was not supported from the beginning will probably remain within the SwiftUI team, however it could have been intentional:
looking at the way SwiftUI stock styles are applied, besides a few parameters passed via the Configuration, most moving parts come from EnvironmentValues, e.g. @Environment(\.isEnabled), @Environment(\.font), and @Environment(\.controlProminence).

Unlike @EnvironmentObject, EnvironmentValues are supported (without 💥!) from iOS 13.0, and it's the way I also recommend going when adding dynamic to our custom styles.

Regardless of whether this was a bug or by design, as an SDK vendor, it's up to us to disambiguate and clarify such scenarios to developers.
As far as I know, this was not documented anywhere, nor was addressed on any release note: if developers misused it, it's on the framework.

Another place where this could have been an issue are view modifiers, however both EnvironmentValues and @EnvironmentObject are supported (without 💥) from iOS 13.0.

Are there any other SwiftUI feature where you've experience similar unexpected behaviors? I have the test setup from this article ready to go: feel free to reach out via email or Twitter, I will be happy to run tests for your case!

⭑⭑⭑⭑⭑

Related articles

More SwiftUI articles

Browse all