DEV Community

Cover image for Environment Objects & SwiftUI Styles
Vadim Atamanenko
Vadim Atamanenko

Posted on

Environment Objects & SwiftUI Styles

SwiftUI environments and styles are the two pillars of Apple's official framework (declarative structure). Despite this, when SwiftUI was first launched, using them together resulted in a guaranteed application crash.

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

Example

Meet FSStyle, a button style that expects 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) { }
  }
}
Enter fullscreen mode Exit fullscreen mode

Based on the definition, we expect everything 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

..and yet if we ran this code on any version of iOS prior to 14.5, it was guaranteed to fail 100% of the time with a Fatal error: No ObservableObject of type FSEnvironmentObject found..

It fails as soon as an environment object is used inside the makeBody(configuration: ) method, and both the definition of the object and the execution of makeBody(configuration: ) are irrelevant.

There are two main ways to work around the bug: either style it to match DynamicProperty(many thanks to Lin Qing Mo for the tip!), or return a View in the makeBody (configuration: ) method and have that View read the environment object.

Now that we have seen what the problem is, let's find out in which iOS versions and styles this error occurs.

Test setup

We want to find out which versions of iOS are safe to use all possible styles (not just button styles). We can create a small test app and run it on all versions of iOS that support SwiftUI and get results.

Continuing with the ButtonStyleexample, here is the complete application:

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"
}
Enter fullscreen mode Exit fullscreen mode

The application consists of a single screen that contains our test component/style.

A few notes:

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

CI/CD setup

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

Testing each combination by hand would be quite difficult, instead we can let the CI/CD provider do all the hard work for us. Any CI/CD setup will do, here's how the different versions of Xcode/iOS were 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

Since the test application has only one screen that immediately displays the component under test, all that is needed to pass the test is to launch the application and not immediately crash. Here is the result:

Image description
πŸ’₯ = crash, βœ… = passed. *Style available since iOS 14.

Let's summarize:

all styles except ButtonStylesupport @EnvironmentObject as of iOS 14.0
since iOS 14.5 all styles including ButtonStylesupport @EnvironmentObject

Conclusions

The reason why styles + @EnvironmentObject was not supported from the start is likely to remain inside the SwiftUI command, however this could have been intentional:

Looking at how standard SwiftUI styles are applied, in addition to the few parameters passed through Configuration, most of the mutable components come from EnvironmentValues, such as @Environment (\.IsEnabled), @Environment (\.font) and @Environment (\.controlProminence).

Unlike @EnvironmentObject, EnvironmentValues ​​has been supported (no crash!) since iOS 13.0, so I recommend them when adding dynamics to our custom styles.

Whether this was a mistake or intentional, as an SDK vendor, it's important for us to disambiguate and clarify such scenarios for developers.

To the best of my knowledge, this has not been documented anywhere and has not been covered in any release notes: if the developers have allowed it, it is in the framework.

Another place where this could be a problem is in View modifiers, however both EnvironmentValues ​​and @EnvironmentObject are supported (without the πŸ’₯) from iOS 13.0.

Top comments (0)