Search code examples
iosswiftmacosswiftui

How to access own window within SwiftUI view?


The goal is to have easy access to hosting window at any level of SwiftUI view hierarchy. The purpose might be different - close the window, resign first responder, replace root view or contentViewController. Integration with UIKit/AppKit also sometimes require path via window, so…

What I met here and tried before,

something like this

let keyWindow = shared.connectedScenes
        .filter({$0.activationState == .foregroundActive})
        .map({$0 as? UIWindowScene})
        .compactMap({$0})
        .first?.windows
        .filter({$0.isKeyWindow}).first

or via added in every SwiftUI view UIViewRepresentable/NSViewRepresentable to get the window using view.window looks ugly, heavy, and not usable.

Thus, how would I do that?


Solution

  • SwiftUI Life-Cycle (SwiftUI 2+)

    Here is a solution (tested with Xcode 13.4), to be brief only for iOS

    1. We need application delegate to create scene configuration with our scene delegate class
    @main
    struct PlayOn_iOSApp: App {
        @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
        // ...
    }
    
    class AppDelegate: NSObject, UIApplicationDelegate {
    
    
        func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
            let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
            if connectingSceneSession.role == .windowApplication {
                configuration.delegateClass = SceneDelegate.self
            }
            return configuration
        }
    }
    
    1. Declare our SceneDelegate and confirm it to both (!!!+) UIWindowSceneDelegate and ObservableObject
    class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
        var window: UIWindow?   // << contract of `UIWindowSceneDelegate`
    
        func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
            guard let windowScene = scene as? UIWindowScene else { return }
            self.window = windowScene.keyWindow   // << store !!!
        }
    }
    
    
    1. Now we can use our delegate anywhere (!!!) in view hierarchy as EnvironmentObject, because (bonus of confirming to ObservableObject) SwiftUI automatically injects it into ContentView
        @EnvironmentObject var sceneDelegate: SceneDelegate
        
        var body: some View {
             // ...       
                .onAppear {
                    if let myWindow = sceneDelegate.window {
                        print(">> window: \(myWindow.description)")
                    }
                }
        }
    

    demo3

    Complete code in project is here

    UIKit Life-Cycle

    Here is the result of my experiments that looks appropriate for me, so one might find it helpful as well. Tested with Xcode 11.2 / iOS 13.2 / macOS 15.0

    The idea is to use native SwiftUI Environment concept, because once injected environment value becomes available for entire view hierarchy automatically. So

    1. Define Environment key. Note, it needs to remember to avoid reference cycling on kept window
    struct HostingWindowKey: EnvironmentKey {
    
    #if canImport(UIKit)
        typealias WrappedValue = UIWindow
    #elseif canImport(AppKit)
        typealias WrappedValue = NSWindow
    #else
        #error("Unsupported platform")
    #endif
    
        typealias Value = () -> WrappedValue? // needed for weak link
        static let defaultValue: Self.Value = { nil }
    }
    
    extension EnvironmentValues {
        var hostingWindow: HostingWindowKey.Value {
            get {
                return self[HostingWindowKey.self]
            }
            set {
                self[HostingWindowKey.self] = newValue
            }
        }
    }
    
    1. Inject hosting window in root ContentView in place of window creation (either in AppDelegate or in SceneDelegate, just once
    // window created here
    
    let contentView = ContentView()
                         .environment(\.hostingWindow, { [weak window] in
                              return window })
    
    #if canImport(UIKit)
            window.rootViewController = UIHostingController(rootView: contentView)
    #elseif canImport(AppKit)
            window.contentView = NSHostingView(rootView: contentView)
    #else
        #error("Unsupported platform")
    #endif
    
    1. use only where needed, just by declaring environment variable
    struct ContentView: View {
        @Environment(\.hostingWindow) var hostingWindow
        
        var body: some View {
            VStack {
                Button("Action") {
                    // self.hostingWindow()?.close() // macOS
                    // self.hostingWindow()?.makeFirstResponder(nil) // macOS
                    // self.hostingWindow()?.resignFirstResponder() // iOS
                    // self.hostingWindow()?.rootViewController?.present(UIKitController(), animating: true)
                }
            }
        }
    }