SwiftUI and the Missing Environment Object

Using ViewModifiers to fix another major issue in SwiftUI development.

Michael Long
Oct 7 · 6 min read

SwiftUI provides us with a quick and easy way to pass models and services down the View hierarchy, the EnvironmentObject.

Just add your model or service to your view as an environmentObject view modifier, and any child view that’s interested can use an EnvironmentObject property wrapper to grab it and use it as they see fit.

It’s a fundamental architectural building block for SwiftUI and it works amazingly well… except when it doesn’t.

An EnvironmentObject example

Here we construct the main content view inside of our app’s SceneDelegate and add some menu, order, and ratings services to it.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {    var menu = MenuService()
var order = Order()
var ratings = RatingService()
func scene(...) {
let contentView = AppTabView()
.environmentObject(menu)
.environmentObject(order)
.environmentObject(ratings)
.accentColor(.red)
...
}

These services may be accessed later on as needed, as shown below in our DetailView. Here we grab the Order object.

struct DetailView: View {
@EnvironmentObject var order: Order
var body: some View {
...
}
}

And we’re good to go using the Order service anywhere in the DetailView.

Extending the iDine application

I based the examples in this series on Paul Hudson’s iDine application. If you’re still learning SwiftUI (and who of us isn’t?) then I highly recommend watching Paul’s video tutorial series on creating this demo application.

So I was working on extending the iDine application and I wanted to add a presentation component so I could play with that part of SwiftUI. I decided to add a ratings screen so users could rate the various menu items on iDine.

The operative code to display the current rating and to present the ratings view looks like this…

struct RatingsView: View {
@State var showRatingSheet = false
let item: MenuItem
var body: some View {
HStack {
...
}
.onTapGesture {
self.showRatingSheet.toggle()
}
.sheet(isPresented: $showRatingSheet) {
RatingsSheet(presented: self.$showRatingSheet, item: self.item)
}
}
}

When the user taps on RatingsView we toggle the showRatingSheet state flag to true, which in turn causes the .sheet(isPresented:content:) modifier to construct and launch a RatingsSheet.

A bit roundabout, but the sheet modifier bound to the showRatingSheet flag is the SwiftUI way.

The RatingsSheet…

The core of RatingsSheet is a VStack that contains several copies of RatingsRow, which in turn look like the following.

struct RatingsRow: View {    @EnvironmentObject var ratings: RatingService    let item: MenuItem
let value: Int
let title: String
var body: some View {
let rating = self.ratings.rating(for: self.item)
return HStack {
Image(systemName: self.value <= rating ? "star.fill" : "star")
Text(title)
Spacer()
}
.onTapGesture {
self.ratings.rate(self.item, as: self.value)
}
}
}

Basically, we grab the global ratings service, use it to get the current rating for the passed menu item and use that result to display a filled or empty star.

If the user taps on this particular rating, we rate that particular item on the rating service.

And now the problem apears…

Feeling somewhat pleased with myself in regard to my solution and my rather neat environment-object-based architecture, I ran the code, selected a Tower Burger from the menu, tapped on the ratings view… and watched the app crash with the following error:

Fatal error: No ObservableObject of type RatingService found.

Examining the crash

The app crashed, but why? We provided the RatingService as an environmentObject modifier at the top of the application, remember? Along with other services we’ve been using along the way.

We found those services, so why can’t we find this one?

Let’s go back to our original definition: “Just add your model or service to your view as an environmentObject view modifier, and any child view that’s interested can use an EnvironmentObject property wrapper to grab it and use it as they see fit.”

Apparently, the operative words in our definition are “any child view”.

Long story short, it seems that when we present a new View or Action Sheet that entity is no longer considered to be in the current view hierarchy.

In effect, presenting a View creates a brand new view hierarchy.

And that, in my opinion, is a bad thing.

But that’s neither here nor there, and something we’ll discuss later. Because at the moment we have a crashing app and it would nice if it stopped doing so.

How to fix it?

So the problem is a missing environment variable. How to fix it?

Well, one solution would be to obtain the environment objects in the parent view and pass them to the presented view as parameters. Messy.

A similar approach would be to obtain the environment objects in the parent view and pass them to the presented view as more environment objects, in effect restoring the chain. A bit neater, but still error prone, especially with complex view presentations.

I mean, are we going to have to grab and pass every needed model and service to every presented view every time we want to use one? Yuck.

There has to be a better way to group our environment modifiers so that we don’t have to specify each and every one each and every time… wait.

Enter SystemServices

SwiftUI does in fact let you group view modifiers into a common entity known as a ViewModifier.

The original intent was to provide a way to create reusable sets of font and color styles that could be used throughout your code as needed, but I thought it could be repurposed to fit our needs.

It does. Here’s my SystemServices view modifier.

struct SystemServices: ViewModifier {    static var menu = MenuService()
static var order = Order()
static var ratings = RatingService()
func body(content: Content) -> some View {
content
// defaults
.accentColor(.red)
// services
.environmentObject(Self.menu)
.environmentObject(Self.order)
.environmentObject(Self.ratings)
}
}

Which replaces the above code at launch…

    let contentView = AppTabView()
.modifier(SystemServices())

And which I now add to my ratings presentation code to pass all of my previously defined services through to my new view hierarchy.

   .sheet(isPresented: $showRatingSheet, content: {
RatingsSheet(...)
.modifier(SystemServices())
})

I suppose one extra line of code isn’t too bad.

Note that our services are defined as global static variables, making them Singletons and ensuring that new instances of SystemServices are using the same shared services as the rest of the application.

So a solution, but it’s still a bug

One could in fact argue that passing only the needed environment object variables through the chain could be a feature. In essence, the idea would be that we’re only passing on the objects that the presented view requires.

Inconvenient, perhaps, but I could understand the rationale.

But the problem is that this particular bug isn’t confined just to environmentObject variables.

Note that in the original code we defined what we thought would be a global accent color that would be used throughout the app… but that isn’t passed along either.

In fact, no environment variable or preference is passed along to the presented view. Too bad if you’d tried .environment(\.colorScheme, .dark) and expected the rest of the application to honor your request.

Fortunately, we can also renew those states using our SystemServices modifier.

All of which is why I consider it to be a bug and not something implemented by design.

Completion block

This issue is present as of Xcode 11.1 and iOS 13.1. In other words, it made it through the beta process to release, and then to the next point release.

It also goes to show that it’s still early days insofar as using SwiftUI in a production environment. You can do it. Just be careful as there’s still a lot of rough edges that haven’t been filed off quite yet.

As always, just leave any questions or comments below.

Note: My version of the iDine application source code is available from my iDine Repository on GitHub. It includes the changes mentioned in this article.

The Startup

Medium's largest active publication, followed by +516K people. Follow to join our community.

Michael Long

Written by

Michael Long (RxSwifty) is a Senior Lead iOS engineer at CRi Solutions, a leader in cutting edge iOS, Android, and mobile corporate and financial applications.

The Startup

Medium's largest active publication, followed by +516K people. Follow to join our community.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade