Aug 23, 2023

ObservableObject initialisation using Environment

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

  @StateObject var viewModel = ViewModel(
    // Attempt 1) 
    // Environment is not available yet ¯\_(ツ)_/¯
    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 FirstScreen: View {
  @Environment(\.fooService) var fooService

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

struct SecondScreen: 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 SecondScreenPreviews: PreviewProvider {
  
  static var previews: some View {
    SecondScreen(fooService: .mock)
  }
}

A step further

Passing the environment via View initializer seems like a scalable solution and it also matches the technique Apple recommends in the documentation for initialization of state objects using external data (although it does not specifically mention environment as one of the options).

There is a small disadvantage with the current approach though. The environment dependencies are not encapsulated in the relevant view anymore, but they need to be moved up to all call sites of that view, regardless of whether they are relevant to the call site 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 when passing the environment from UIViewControllerRepresentable back to SwiftUI view, as otherwise, we would need to reapply all values and objects manually.

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

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

struct FirstScreen: View {
  // Obtain the whole environment in a single container
  @Environment(\.self) var environment

  var body: some View {
    content
      .sheet(...) {
        SecondScreen(
          // Pass the environment as a single item
          environment: environment
        )
      }
  }
}

struct SecondScreen: View {
  @StateObject var viewModel: ViewModel

  init(environment: EnvironmentValues) {
    self._viewModel = .init(
      // Either the environment is passed to ObservableObject as-is,
      // or 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 an 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 SecondScreen: 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 SecondScreenPreviews: PreviewProvider {

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

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

Search
Share
Featured articles
Generating SwiftUI snapshot tests with Swift macros
Don’t Fix Bad Data, Do This Instead