Image for post
Image for post

SwiftUI and the Missing Environment Object

Using ViewModifiers to fix another major issue in SwiftUI development.

Michael Long
Oct 7, 2019 · 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.

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.

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 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.

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.

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.

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.

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.

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.

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.

If you enjoyed this article then try my latest: Deep Inside Views, State and Performance in SwiftUI. Or why almost none of those words mean what you think they mean…

The Startup

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

Michael Long

Written by

Michael Long 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 +717K people. Follow to join our community.

Michael Long

Written by

Michael Long 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 +717K people. Follow to join our community.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store