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) { }
}
}
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)
}
}
..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 themakeBody (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 ButtonStyle
example, 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"
}
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:
π₯ = crash, β
= passed. *Style available since iOS 14.
Let's summarize:
all styles except ButtonStyle
support @EnvironmentObject
as of iOS 14.0
since iOS 14.5 all styles including ButtonStyle
support @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)