Behavior of environment values inside task

When using environment values inside a task, changes to them are not reflected like with @State properties. Why is that? Is this expected behavior or a bug?

For example, changes to colorScheme after .task{...} has started are not reflected, while changes to the @State property foregroundColor are.

import SwiftUI
import AsyncAlgorithms

struct ContentView: View {
  @Environment(\.colorScheme) private var colorScheme

  @State private var foregroundColor = Color.yellow

  var body: some View {
    VStack {
      Rectangle()
        .fill(foregroundColor)
        .scaledToFit()
        .frame(width: 50)

      Text("\(colorScheme)")
    }
    .task {
      for await _ in AsyncTimerSequence(interval: .seconds(3), clock: .continuous) {
        print(colorScheme, foregroundColor)
      }
    }
    .onChange(of: colorScheme, initial: true) {
      switch colorScheme {
      case .light: foregroundColor = .blue
      case .dark: foregroundColor = .red
      @unknown default: return
      }
    }
  }
}

Task closure captures a copy of self including _colorScheme and _foregroundColor. State stores reference to the storage, and two copies share storage between them. So changes in the storage are visible through both copies. But Environment, I guess, stores a copy of the value, and not the storage reference. So new value is written into the copy of the Environment inside the view, but copy captured by the closure sees the old value.

1 Like

I believe that the reason for this is that environment isn’t a mutable thing, it only changes when a parent view passes a new value. You can apply the same mental model as you would use when a parent view passes an Int to a child view; the body captures the value of that integer at the point that the body gets computed, due to views being structs. Environment is just a convenient way to pass universally applicable values through the view hierarchy automatically, but in practice it’s not much different to just manually passing immutable values to child views.

2 Likes

As for the rationale behind it, I assume it's the same idea as with environment variables for child processes:
The parent sets the environment variables, but it makes little sense for the child to mutate them, conceptually.

Obviously I am not an Apple Engineer and can only guess as to the design principles used for SwiftUI here, but that's always been the way I interpreted it. Parent views can define the environment their child views "run" in just like parent processes can set the environment variables of a child process.

2 Likes