SwiftUI: observe @Environment property changes

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


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

Tags:

Ios

Swift

Swiftui