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