Reading from the Window in a SwiftUI lifecycle app
With the new SwiftUI lifecycle apps we sometimes still need to read information from the underlying UIKit and AppKit controls. In this post I will share my method for reading values from the window, in particular observing when the window is the key
window. However the method I have used can also be used to track other values from the window and also expose Window level apis such as the makeKeyAndOrderFront method used to pull focus to a window.
Firstly since I would like this to work both on iOS and macOS I will create a typealias
to minimize the number of #if canImport(UIKit)
we need to use.
#if canImport(UIKit)
typealias Window = UIWindow
#elseif canImport(AppKit)
typealias Window = NSWindow
#else
#error("Unsupported platform")
#endif
When compiling on platforms with UIKit
the Window
type will be set to UIWindow and when compiling on platforms with AppKit
the Window
type is set to NSWindow.
Finding the correct Window
Since apps running on both iOS and macOS support multiple Scenes each app might have many window instances but for our use case within SwiftUI we would like to get the current window of the views in question. Unfortunately to do this in SwiftUI we need to revert to the hacky solution of injecting a NSView (or UIView) as a child and then reading out the .window
property that all NSViews/UIViews
expose once added to the view hierarchy.
#if canImport(UIKit)
struct HostingWindowFinder: UIViewRepresentable {
var callback: (Window?) -> ()
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
#elseif canImport(AppKit)
struct HostingWindowFinder: NSViewRepresentable {
var callback: (Window?) -> ()
func makeNSView(context: Self.Context) -> NSView {
let view = NSView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
#else
#error("Unsupported platform")
#endif
Due to the the method names in UIViewRepresentable and NSViewRepresentable being different I find it cleanest to just fully define them using the same #if .. #elseif ...
pattern.
In these views we create a NSView
/UIView
and then schedule a callback to run on the next runloop that will read the .window value from these. Another solution would be to sub-class the views and extract the window
in the didMoveToSuperview method but the above dispatch onto the next iteration of the runloop seems to do the trick.
Using the HostingWindowFinder
View
Since we need to create and insert a dummy view we need to be a little careful to ensure that when we do this we do not mess with our view layout.
ContentView().background(
HostingWindowFinder { window in
print(window)
}
)
By placing the HostingWindowFinder
view as a background view it will not disturb the layout.
Storing the Window
Since the HostingWindowFinder
will only callback once when first inserting the NSView
/UIView
we need to store a reference to this window. weak reference to the Window to avoid possible reference cycles. The best way to do this for SwiftUI is to create a ObservableObject and set this as a StateObject that can be owned
by a view.
class WindowObserver: ObservableObject {
weak var window: Window?
}
struct ContentView: View {
@StateObject
var windowObserver: WindowObserver = WindowObserver()
var body: some View {
Text("Hello World").background(
HostingWindowFinder { [weak windowObserver] window in
windowObserver?.window = window
}
)
}
}
Here we ensure that the callback we pass to our HostingWindowFinder
also uses a weak value for the WindowObserver
.
Observing when the Window becomes the Key Window
class WindowObserver: ObservableObject {
@Published
public private(set) var isKeyWindow: Bool = false
private var becomeKeyobserver: NSObjectProtocol?
private var resignKeyobserver: NSObjectProtocol?
weak var window: Window? {
didSet {
self.isKeyWindow = window?.isKeyWindow ?? false
guard let window = window else {
self.becomeKeyobserver = nil
self.resignKeyobserver = nil
return
}
self.becomeKeyobserver = NotificationCenter.default.addObserver(
forName: Window.didBecomeKeyNotification,
object: window,
queue: .main
) { (n) in
self.isKeyWindow = true
}
self.resignKeyobserver = NotificationCenter.default.addObserver(
forName: Window.didResignKeyNotification,
object: window,
queue: .main
) { (n) in
self.isKeyWindow = false
}
}
}
}
By setting up two observes that observe the NotificationCenter events for didResignKeyNotification and didBecomeKeyNotification we can update our @Published property isKeyWindow
. If you need to extract other values from the Window you should look for the Notification names that correspond to these values changing.
Wrapping this up into a ViewModifier
To keep our code nice and clean I like to wrap logic like this up into a ViewModifier and then expose the isKeyWindow
value using an EnvironmentValue to the wrapped content.
struct WindowObservationModifier: ViewModifier {
@StateObject
var windowObserver: WindowObserver = WindowObserver()
func body(content: Content) -> some View {
content.background(
HostingWindowFinder { [weak windowObserver] window in
windowObserver?.window = window
}
).environment(
\.isKeyWindow,
windowObserver.isKeyWindow
)
}
}
We also need to declare the isKeyWindow
EnvironmentKey.
extension EnvironmentValues {
struct IsKeyWindowKey: EnvironmentKey {
static var defaultValue: Bool = false
typealias Value = Bool
}
fileprivate(set) var isKeyWindow: Bool {
get {
self[IsKeyWindowKey.self]
}
set {
self[IsKeyWindowKey.self] = newValue
}
}
}
Then we can set this directly on our ContentView
at the top level in our App.
@main
struct ExampleWindowReaderApp: App {
var body: some Scene {
WindowGroup {
ContentView().modifier(WindowObservationModifier())
}
}
}
Reading the isKeyWindow
EnvironmentValue
Like any EnvironmentValues value to read the value we use the @Environment property wrapper.
@Environment(\.isKeyWindow)
var isKeyWindow: Bool
Then within our view body we can access this value.
In this post I discussed how to expose the isKeyWindow
of our UIKit
/AppKit
window however the same method can be used to extract any property on these classes or even expose functions of these classes to wrapped views through the EnvironmentValues method.
You can find the full code example of this post on Github.