Reputation: 258345
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?
Upvotes: 33
Views: 40659
Reputation: 258345
Here is a solution (tested with Xcode 13.4), to be brief only for iOS
@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
}
}
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 !!!
}
}
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)")
}
}
}
Complete code in project is here
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
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
}
}
}
// 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
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)
}
}
}
}
Upvotes: 45
Reputation: 11427
Maybe not best solution, but works well for me and enough universal for almost any situation
Usage:
someView()
.wndAccessor {
$0?.title = String(localized: "This is a new window title")
}
extension code:
import SwiftUI
@available(OSX 11.0, *)
public extension View {
func wndAccessor(_ act: @escaping (NSWindow?) -> () )
-> some View {
self.modifier(WndTitleConfigurer(act: act))
}
}
@available(OSX 11.0, *)
struct WndTitleConfigurer: ViewModifier {
let act: (NSWindow?) -> ()
@State var window: NSWindow? = nil
func body(content: Content) -> some View {
content
.getWindow($window)
.onChange(of: window, perform: act )
}
}
//////////////////////////////
///HELPERS
/////////////////////////////
// Don't use this:
// Usage:
//.getWindow($window)
//.onChange(of: window) { _ in
// if let wnd = window {
// wnd.level = .floating
// }
//}
@available(OSX 11.0, *)
private extension View {
func getWindow(_ wnd: Binding<NSWindow?>) -> some View {
self.background(WindowAccessor(window: wnd))
}
}
@available(OSX 11.0, *)
private struct WindowAccessor: NSViewRepresentable {
@Binding var window: NSWindow?
public func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window
}
return view
}
public func updateNSView(_ nsView: NSView, context: Context) {}
}
Upvotes: 1
Reputation: 621
Getting a window from AppDelegate, SceneDelegate or keyWindow is not suitable for multi-window app.
Here's my solution:
import UIKit
import SwiftUI
struct WindowReader: UIViewRepresentable {
let handler: (UIWindow?) -> Void
@MainActor
final class View: UIView {
var didMoveToWindowHandler: ((UIWindow?) -> Void)
init(didMoveToWindowHandler: (@escaping (UIWindow?) -> Void)) {
self.didMoveToWindowHandler = didMoveToWindowHandler
super.init(frame: .null)
backgroundColor = .clear
isUserInteractionEnabled = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMoveToWindow() {
super.didMoveToWindow()
didMoveToWindowHandler(window)
}
}
func makeUIView(context: Context) -> View {
.init(didMoveToWindowHandler: handler)
}
func updateUIView(_ uiView: View, context: Context) {
uiView.didMoveToWindowHandler = handler
}
}
extension View {
func onWindowChange(_ handler: @escaping (UIWindow?) -> Void) -> some View {
background {
WindowReader(handler: handler)
}
}
}
// on your SwiftUI view side:
struct MyView: View {
var body: some View {
Text("")
.onWindowChange { window in
print(window)
}
}
}
Upvotes: 2
Reputation: 720
Access the current window by receiving NSWindow.didBecomeKeyNotification
:
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
if let window = notification.object as? NSWindow {
// ...
}
}
Upvotes: 13
Reputation: 190
Instead of ProjectName_App use old fashioned AppDelegate approach as app entry point.
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
...
}
}
Then pass window as environment object. For example:
struct WindowKey: EnvironmentKey {
static let defaultValue: UIWindow? = nil
}
extension EnvironmentValues {
var window: WindowKey.Value {
get { return self[WindowKey.self] }
set { self[WindowKey.self] = newValue }
}
}
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
let rootView = RootView()
.environment(\.window, window)
window?.rootViewController = UIHostingController(rootView: rootView)
window?.makeKeyAndVisible()
}
}
And use it when need it.
struct ListCell: View {
@Environment(\.window) private var window
var body: some View {
Rectangle()
.onTapGesture(perform: share)
}
private func share() {
let vc = UIActivityViewController(activityItems: [], applicationActivities: nil)
window?.rootViewController?.present(vc, animated: true)
}
}
Upvotes: 2
Reputation: 41718
Add the window as a property in an environment object. This can be an existing object that you use for other app-wide data.
final class AppData: ObservableObject {
let window: UIWindow? // Will be nil in SwiftUI previewers
init(window: UIWindow? = nil) {
self.window = window
}
}
Set the property when you create the environment object. Add the object to the view at the base of your view hierarchy, such as the root view.
let window = UIWindow(windowScene: windowScene) // Or however you initially get the window
let rootView = RootView().environmentObject(AppData(window: window))
Finally, use the window in your view.
struct MyView: View {
@EnvironmentObject private var appData: AppData
// Use appData.window in your view's body.
}
Upvotes: 5
Reputation: 1571
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.
Upvotes: 0