How to Access Own Window Within Swiftui View

How to access own window within SwiftUI view?

SwiftUI Lift-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)
}
}
}
}

How to access NSWindow from @main App using only SwiftUI?

Basically, I'm trying to do something like:

LoginView(store: AuthStore(window: window))

Here is a demo of possible approach (with some replicated entities)

demo

class AuthStore {
var window: NSWindow

init(window: NSWindow) {
self.window = window
}
}

struct DemoWindowAccessor: View {
@State private var window: NSWindow? // << detected in run-time so optional
var body: some View {
VStack {
if nil != window {
LoginView(store: AuthStore(window: window!)) // << usage
}
}.background(WindowAccessor(window: $window))
}
}

struct WindowAccessor: NSViewRepresentable {
@Binding var window: NSWindow?

func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
self.window = view.window // << right after inserted in window
}
return view
}

func updateNSView(_ nsView: NSView, context: Context) {}
}

struct LoginView: View {
let store: AuthStore

var body: some View {
Text("LoginView with Window: \(store.window)")
}
}

SwiftUI - Display view on UIWindow

I managed to do what I wanted.
Basically, this code is working, but I had to remove some constraints from my SwiftUI view and add them with UIKit using the static func.
Also, I had to pass by a modifier (see below) and put ToastView init in private.

public struct ToastModifier: ViewModifier {
public func body(content: Content) -> some View {
content
}
}

extension View {
public func toast() -> some View {
ToastView.displayToastAboveAll()
return modifier(ToastModifier())
}
}

This is done to force the use of either modifier (SwiftUI, by doing .toast, just like you'd do .alert) or directly by calling the static func ToastView.displayToastAboveAll() (UIKit).
Indeed, I dont wont this Toast to be a part of the view, I want to trigger it like an alert.

Finally, special warning because passing ToastView into UIHostingViewController will mess with some of the animations.
I had to rewrite animations in UIKit in order to have a nice swipe & fade animation.

How to get UIWindow value in iOS 14 @main file?

From iOS 13 onwards, it's safe to assume that the correct way to obtain a reference to the key window is via UIWindowSceneDelegate.

@main
struct DemoApp: App {

var window: UIWindow? {
guard let scene = UIApplication.shared.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window else {
return nil
}
return window
}

[...]
}

How to implement multi window with menu commands in SwiftUI for macOS?

Useful links:

  1. How to access NSWindow from @main App using only SwiftUI?
  2. How to access own window within SwiftUI view?
  3. https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/

(this is what I was able to come up with, if anyone has a better idea/approach, please share)

The idea is to create a shared "global" view model that keeps track of opened windows and view models. Each NSWindow has an attribute with a unique windowNumber. When a window becomes active (key), it looks up the view model by the windowNumber and sets it as the activeViewModel.

import SwiftUI

class GlobalViewModel : NSObject, ObservableObject {

// all currently opened windows
@Published var windows = Set()

// all view models that belong to currently opened windows
@Published var viewModels : [Int:ViewModel] = [:]

// currently active aka selected aka key window
@Published var activeWindow: NSWindow?

// currently active view model for the active window
@Published var activeViewModel: ViewModel?

func addWindow(window: NSWindow) {
window.delegate = self
windows.insert(window)
}

// associates a window number with a view model
func addViewModel(_ viewModel: ViewModel, forWindowNumber windowNumber: Int) {
viewModels[windowNumber] = viewModel
}
}

Then, react on every change on window (when it is being closed and when it becomes an active aka key window):

import SwiftUI

extension GlobalViewModel : NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow {
windows.remove(window)
viewModels.removeValue(forKey: window.windowNumber)
print("Open Windows", windows)
print("Open Models", viewModels)
}
}
func windowDidBecomeKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
print("Activating Window", window.windowNumber)
activeWindow = window
activeViewModel = viewModels[window.windowNumber]
}
}
}

Provide a way to lookup window that is associated to the current view:

import SwiftUI

struct HostingWindowFinder: NSViewRepresentable {
var callback: (NSWindow?) -> ()

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

Here is the view that is updating the global view model with the current window and viewModel:

import SwiftUI

struct ContentView: View {
@EnvironmentObject var globalViewModel : GlobalViewModel

@StateObject var viewModel: ViewModel = ViewModel()

var body: some View {
HostingWindowFinder { window in
if let window = window {
self.globalViewModel.addWindow(window: window)
print("New Window", window.windowNumber)
self.globalViewModel.addViewModel(self.viewModel, forWindowNumber: window.windowNumber)
}
}

TextField("", text: $viewModel.inputText)
.disabled(true)
.padding()
}
}

Then we need to create the global view model and send it to the views and commands:

import SwiftUI

@main
struct multi_window_menuApp: App {

@State var globalViewModel = GlobalViewModel()

var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(self.globalViewModel)
}
.commands {
MenuCommands(globalViewModel: self.globalViewModel)
}

Settings {
VStack {
Text("My Settingsview")
}
}
}
}

Here is how the commands look like, so they can access the currently selected/active viewModel:

import Foundation
import SwiftUI
import Combine

struct MenuCommands: Commands {
var globalViewModel: GlobalViewModel

var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
let dialog = NSOpenPanel();

dialog.title = "Choose a file";
dialog.showsResizeIndicator = true;
dialog.showsHiddenFiles = false;
dialog.allowsMultipleSelection = false;
dialog.canChooseDirectories = false;

if (dialog.runModal() == NSApplication.ModalResponse.OK) {
let result = dialog.url
if (result != nil) {
let path: String = result!.path
do {
let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
print("Active Window", self.globalViewModel.activeWindow?.windowNumber)
self.globalViewModel.activeViewModel?.inputText = string
}
catch {
print("Error \(error)")
}
}
} else {
return
}
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: [.command])
})
}
}

All is updated and runnable under this github project: https://github.com/ondrej-kvasnovsky/swiftui-multi-window-menu



Related Topics



Leave a reply



Submit