How to access own window within SwiftUI view?

2020-04-30 03:35发布

问题:

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?

回答1:

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
        }
    }
}

2) 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

3) 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)
            }
        }
    }
}


回答2:

At first I liked the answer given by @Asperi, but when trying it in my own environment I found it difficult to get working due to my need to know the root view at the time I create the window (hence I don't know the window at the time I create the root view). So I followed his example, but instead of an environment value I chose to use an environment object. This has much the same effect, but was easier for me to get working. The following is the code that I use. Note that I have created a generic class that creates an NSWindowController given a SwiftUI view. (Note that the userDefaultsManager is another object that I need in most of the windows in my application. But I think if you remove that line plus the appDelegate line you would end up with a solution that would work pretty much anywhere.)

class RootViewWindowController<RootView : View>: NSWindowController {
    convenience init(_ title: String,
                     withView rootView: RootView,
                     andInitialSize initialSize: NSSize = NSSize(width: 400, height: 500))
    {
        let appDelegate: AppDelegate = NSApplication.shared.delegate as! AppDelegate
        let windowWrapper = NSWindowWrapper()
        let actualRootView = rootView
            .frame(width: initialSize.width, height: initialSize.height)
            .environmentObject(appDelegate.userDefaultsManager)
            .environmentObject(windowWrapper)
        let hostingController = NSHostingController(rootView: actualRootView)
        let window = NSWindow(contentViewController: hostingController)
        window.setContentSize(initialSize)
        window.title = title
        windowWrapper.rootWindow = window
        self.init(window: window)
    }
}

final class NSWindowWrapper: ObservableObject {
    @Published var rootWindow: NSWindow? = nil
}

Then in my view where I need it (in order to close the window at the appropriate time), my struct begins as the following:

struct SubscribeToProFeaturesView: View {
    @State var showingEnlargedImage = false
    @EnvironmentObject var rootWindowWrapper: NSWindowWrapper

    var body: some View {
        VStack {
            Text("Professional Version Upgrade")
                .font(.headline)
            VStack(alignment: .leading) {

And in the button where I need to close the window I have

self.rootWindowWrapper.rootWindow?.close()

It's not quite as clean as I would like it to be (I would prefer to have a solution where I did just say self.rootWindow?.close() instead of requiring the wrapper class), but it isn't bad and it allows me to create the rootView object before I create the window.