ObservableObject initialization using Environment

Pavel Holec
4 min readAug 23, 2023

--

At Kiwi.com, the conversion of architecture from UIKit to pure SwiftUI was not without hiccups. One of them was finding a way to properly initialize an ObservableObject that depends on the environment that is injected from above.

For the purpose of this article, our ObservableObjects represent optional testable and mockable view models that drive the domain logic for some of the more complicated screens or flows in our app.

A side note on the SwiftUI environment for following code examples: As our app is driven by feature-based modular architecture, we are using Environment instead of EnvironmentObjects for our modular dependencies. This approach has some disadvantages (for example not being able to fully use @Published properties), but in our case, the modularity aspect overweights these limitations.

Using environment to initialize state objects

Problem

We want an ObservableObject (or any state or state object in general) to be initialized using an injected environment. The following naive attempts (1) and (2) do not compile, because the environment is not yet available during View initialization:

struct SomeScreen: View {
// Injected dependency
@Environment(\.fooService) var fooService

// Attempt 1)
// Environment is not available yet ¯\_(ツ)_/¯
@StateObject var viewModel = ViewModel(fooService: fooService)

init() {
// Attempt 2)
// Neither is the environment available here
_viewModel = .init(wrappedValue: .init(fooService: fooService))
}
}

There are workarounds to mitigate this inconvenience, such as setting the StateObject to a working state inside the onAppear() or task() block, but it seems each of these workarounds has some other disadvantage.

By trying out those workarounds, together with my colleagues Šimon Javora and Aliaksandr Baranouski, we came up with a pattern that seems scalable enough.

Solution

For an ObservableObject that needs access to environment, the crux is to initiate such object (2) using the environment passed through the view init parameters (1), instead of using the actual @Environment(Object) (3) from within the view:

struct ParentScreen: View {
@Environment(\.fooService) var fooService

var body: some View {
content
.sheet(...) {
ChildScreen(
// 1) Here we DO have access to environment
fooService: fooService
)
}
}
}

struct ChildScreen: View {
// 3) @Environment(\.fooService) var fooService
@StateObject var viewModel: ViewModel

init(fooService: FooService) {
// 2) The ObservableObject initialisation is encapsulated here
self._viewModel = .init(
// 4) the autoclosure initializer needs to be used
wrappedValue: .init(fooService: fooService)
)
}
}

Please note the init(wrappedValue:) (4) is being used for the StateObject initialisation. It is a technical detail, but an important one. Without this initialiser (which uses @autoclosure to control a single initialisation and assignment), almost all other ways would result in the ObservableObject possibly being re-initiated many times during the same screen lifecycle (and sometimes not even being reassigned on 2nd and following run); which is almost never intended.

Preview and snapshot code is using the very same syntax. Both the whole navigation flow or an isolated screen behaves just like in the actual app, the only difference being the usage of mocked environment dependencies.

struct ChildScreenPreviews: PreviewProvider {

static var previews: some View {
ChildScreen(fooService: .mock)
}
}

A step further

Passing the environment via View initializer also matches the technique Apple recommends in the documentation for initialization of state objects using external data, although it does not specifically mention using environment as one of the options.

There is a small disadvantage with the current approach. The environment dependency is not encapsulated in the relevant view anymore, but it needs to be moved up to all call sites of that view, no matter whether the required environment is also relevant to them or not. We will now mitigate that with one additional technique.

Passing the environment in a single parameter

A technique my colleague Šimon Javora found is that the whole environment container can be obtained as a single property that includes all environment keys and objects.

// Get the whole enviroment
@Environment(\.self) var environment

This single property can be queried, but it can even be reapplied to any view. This can be helpful for example when passing the environment from UIViewControllerRepresentable back to SwiftUI view, as otherwise we would need to reapply all values and objects manually one by one.

view
// Reapply the whole enviroment
.environment(\.self, environment)

We will use this technique to improve the encapsulation of relevant environment values, so that the only environment the parent view needs to care about is that single environment container. This whole container is then passed to the child view initializer, possibly all the way down to the ObservableObject initializer.

struct ParentScreen: View {
// All the parent view needs to define is this property
@Environment(\.self) var environment

var body: some View {
content
.sheet(...) {
ChildScreen(
// We pass the whole environment as a single item
environment: environment
)
}
}
}

struct ChildScreen: View {
@StateObject var viewModel: ViewModel

init(environment: EnvironmentValues) {
self._viewModel = .init(
// The environment is passed to ObservableObject.
// Alternatively, specific values can be extracted and passed further.
wrappedValue: .init(environment: environment)
)
}
}

Bonus

For snapshots that need to capture the screen state after an asynchronous action that is difficult to mock purely by injected environment, ObservableObject can be also prepared with overridden, hardcoded, properties.

For such cases, an additional fileprivate init allows ObservableObject to be injected in place of the StateObject directly.

struct ChildScreen: View {

@StateObject var viewModel: ViewModel

// Regular init that constructs a VM from environment (or other data)
init(environment: EnvironmentValues) {
self._viewModel: .init(wrappedValue: .init(environment: environment))
}

// An optional init for mocked ObservableObject instances
fileprivate init(viewModel: ViewModel) {
self._viewModel = .init(wrappedValue: viewModel)
}
}

struct ChildScreenPreviews: PreviewProvider {

static var previews: some View {
ChildScreen(
// A mocked VM is used directly
viewModel: .mock
// Optionally hardcoding properties to specific state
.mock(\.state, to: .loading)
)
}
}

Now it should be one less trouble on a path to convert your codebase to pure SwiftUI. Happy coding!

--

--

Pavel Holec

iOS Staff Engineer @ Kiwi.com. Working on a ScanQuik photo scanner app in a free time.