Reading from the Window in a SwiftUI lifecycle app


Update: In macOS 12 and iPadOS 15 we also have a a new api to make this simpler.


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.