Kai Oelfke

SwiftUI Environment: How changed EnvironmentValues propagate to child views

Background:

While working on a side project for tracking expenses, I noticed an issue with location updates. The app determines the closest place e.g. a restaurant based on the current location. This is used to prefill the expense form. Nobody likes spending time entering data. So this is one of the main conveniences why I made the app in the first place.

Sometimes the right place showed up on my phone or watch. And sometimes there was an infinite spinner for loading locations.

I tested and confirmed that my location dependency and my ViewModifier to embed the current location into the SwiftUI Environment received updated locations.

But my expense form still didn’t update.

Environment and Sheets

When SwiftUI was released, the EnvironmentValues were not propagated to modal views like Sheets. But this was confirmed to be a bug by Apple and fixed in Xcode 12 / iOS 14.

See Sheets don’t inherit the environment for more details.

Changing EnvironmentValues and view updates

“If the value changes, SwiftUI updates any parts of your view that depend on the value. ” Apple Documentation

The ViewModifier I used to add the current location into the Environment looks almost like this.

struct LocationViewModifier: ViewModifier {
    @State private var currentLocation: CLLocation?

    // [...]

    func body(content: Content) -> some View {
        content
        .onReceive(locationPublisher) { location in
            self.currentLocation = location
        }
        .environment(\.currentLocation, currentLocation)
    }
}

I use this ViewModifier on the root view. So I assumed the newest environment value should be available everywhere in the view hierarchy. The onReceive closure was executed with every location update. Most views reflected the current location correctly.

Making a sample project

To verify my understanding of the SwiftUI Environment I made a sample project with a ViewModifier that keeps changing an environment value.

struct MyModifier: ViewModifier {
  @State var value = "Initial value from modifier"
  @State var color: Color = .primary
  func body(content: Content) -> some View {
    content
    .onAppear {
      Task {
        while true {
          try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
          value = "value \((0...100).randomElement()!)"
          color = [Color.blue, .red, .yellow, .orange, .green].randomElement()!
        }
      }
    }
    .myCustomValue(value)
    .foregroundColor(color)
  }
}

private struct MyEnvironmentKey: EnvironmentKey {
  static let defaultValue: String = "Default value from Environment Key"
}

extension EnvironmentValues {
  var myCustomValue: String {
    get { self[MyEnvironmentKey.self] }
    set { self[MyEnvironmentKey.self] = newValue }
  }
}

extension View {
  func myCustomValue(_ myCustomValue: String) -> some View {
    environment(\.myCustomValue, myCustomValue)
  }
}

The MyModifier ViewModifier keeps changing the two @State variables value and color every second. They’re then injected into the Environment. So the foregroundColor and value updates should be propagated to all child views unless something else overrides the environment again.

At least that’s what I expected.

struct Inner: View {
  @Environment(\.myCustomValue) private var value
  var body: some View {
    Text("Inner Child: \(value)")
  }
}

struct SheetView: View {
  @Environment(\.myCustomValue) private var value
  var body: some View {
    Text("Child Sheet: \(value)")
  }
}

struct ContentView: View {
  @State private var showSheet = true
  var body: some View {
    VStack {
      Inner()
      Button("Show sheet") {
        showSheet = true
      }
      .sheet(isPresented: $showSheet) {
        SheetView()
      }
    }
    .modifier(MyModifier())
  }
}

When running this on an iOS 16.2 simulator with Xcode 14.2 you get this result.

SheetView

The sheet receives the initial environment value from the modifier. Otherwise, the text would use the default value from the EnvironmentKey definition. But the sheet gets no updates. The color and value doesn’t change.

The inner child view work as expected.

Inner Child

It’s interesting to see what happens, when dismissing the sheet and reopening it.

Sheet Dismiss and Open

Sheets capture the current environment value and then ignore updates. I can’t say, if this is a bug or the correct behavior. But it is definitely not how I would assume things to work.

Out of curiosity, I ran this on older iOS simulators. As mentioned before, on iOS 13 sheets don’t inherit any EnvironmentValues, but this was changed with iOS 14.

Sheet Drag Dismiss on iOS 14

On iOS 14.5 sheets lose their EnvironmentValues when doing an interactive dismissal with a drag gesture. The values are reset to the default value from the EnvironmentKey. This doesn’t seem to happen on iOS 15 and higher.

I’m not sure, if there’s a better solution than to reapply relevant EnvironmentValues for every sheet. When developing, I don’t want to think about whether a sheet works fine with only the initial EnvironmentValues or if it needs to receive updates to function properly.

Find the sample code in a gist here.

Stay in the loop

... or just follow via Twitter and RSS.