'SwiftUI: observe @Environment property changes

I was trying to use the SwiftUI @Environment property wrapper, but I can't manage to make it work as I expected. Please, help me understanding what I'm doing wrong.

As an example I have an object that produces an integer once per second:

class IntGenerator: ObservableObject {
    @Published var newValue = 0 {
        didSet {
            print(newValue)
        }
    }

    private var toCanc: AnyCancellable?

    init() {
        toCanc = Timer.TimerPublisher(interval: 1, runLoop: .main, mode: .default)
            .autoconnect()
            .map { _ in Int.random(in: 0..<1000) }
            .assign(to: \.newValue, on: self)
    }
}

This object works as expected since I can see all the integers generated on the console log. Now, let's say we want this object to be an environment object accessible from all over the app and from whoever. Let's create the related environment key:

struct IntGeneratorKey: EnvironmentKey {
    static let defaultValue = IntGenerator()
}

extension EnvironmentValues {
    var intGenerator: IntGenerator {
        get {
            return self[IntGeneratorKey.self]
        }
        set {
            self[IntGeneratorKey.self] = newValue
        }
    }
}

Now I can access this object like this (for example from a view):

struct TestView: View {
    @Environment(\.intGenerator) var intGenerator: IntGenerator

    var body: some View {
        Text("\(intGenerator.newValue)")
    }
}

Unfortunately, despite the newValue being a @Published property I'm not receiving any update on that property and the Text always shows 0. I'm sure I'm missing something here, what's going on? Thanks.



Solution 1:[1]

Environment gives you access to what is stored under EnvironmentKey but does not generate observer for its internals (ie. you would be notified if value of EnvironmentKey changed itself, but in your case it is instance and its reference stored under key is not changed). So it needs to do observing manually, is you have publisher there, like below

@Environment(\.intGenerator) var intGenerator: IntGenerator

@State private var value = 0
var body: some View {
    Text("\(value)")
        .onReceive(intGenerator.$newValue) { self.value = $0 }
}

and all works... tested with Xcode 11.2 / iOS 13.2

backup

Solution 2:[2]

I don't have a definitive answer for how exactly Apple dynamically sends updates to it's standard Environment keys (colorScheme, horizontalSizeClass, etc) but I do have a solution and I suspect Apple does something similar behind the scenes.

Step One) Create an ObservableObject with an @Published properties for your values.

class IntGenerator: ObservableObject {
    
    @Published var int = 0
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        Timer.TimerPublisher(interval: 1, runLoop: .main, mode: .default)
            .autoconnect()
            .map { _ in Int.random(in: 0..<1000) }
            .assign(to: \.int, on: self)
            .store(in: &cancellables)
    }
    
}

Step Two) Create a custom Environment key/value for your property. Here is the first difference between your existing code. Instead of using IntGenerator you'll have an EnvironmentKey for each individual @Published property from step 1.

struct IntKey: EnvironmentKey {
    static let defaultValue = 0
}

extension EnvironmentValues {
    var int: Int {
        get {
            return self[IntKey.self]
        }
        set {
            self[IntKey.self] = newValue
        }
    }
}

Step Three - UIHostingController Approach) This is if you are using an App Delegate as your life cycle (aka a UIKit app w/ Swift UI features). Here is the secret to how we'll be able to dynamically update our Views when our @Published properties change. This simple wrapper View will retain an instance of IntGenerator and update our EnvironmentValues.int when our @Published property value changes.

struct DynamicEnvironmentView<T: View>: View {
    
    private let content: T
    @ObservedObject var intGenerator = IntGenerator()
    
    public init(content: T) {
        self.content = content
    }
    
    public var body: some View {
        content
            .environment(\.int, intGenerator.int)
    }
}

Let us make it easy to apply this to an entire feature's view hierarchy by creating a custom UIHostingController and utilizing our DynamicEnvironmentView. This subclass automatically wraps your content inside a DynamicEnvironmentView.

final class DynamicEnvironmentHostingController<T: View>: UIHostingController<DynamicEnvironmentView<T>> {
    
    public required init(rootView: T) {
        super.init(rootView: DynamicEnvironmentView(content: rootView))
    }
    
    @objc public required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Here is how we use of new DynamicHostingController

let contentView = ContentView()
window.rootViewController = DynamicEnvironmentHostingController(rootView: contentView)

Step Three - Pure Swift UI App Approach) This is if you are using a pure Swift UI app. In this example our App retains the reference to the IntGenerator but you can play around with different architectures here.

@main
struct MyApp: App {
    
    @ObservedObject var intGenerator = IntGenerator()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.int, intGenerator.int)
        }
    }
}

Step Four) Lastly here is how we actually use our new EnvironmentKey in any View we need access to the int. This View will automatically be rebuilt any time the int value updates on our IntGenerator class!

struct ContentView: View {
    
    @Environment(\.int) var int
    
    var body: some View {
        Text("My Int Value: \(int)")
    }
}

Works/Tested in iOS 14 on Xcode 12.2

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 anders