hillmark
hillmark

Reputation: 827

SwiftUI macos NSWindow instance

Using Xcode 12.3 and Swift 5.3 with the SwiftUI App lifecycle to build a macOS application, what is the best way to access and change the appearance and behaviour of the NSWindow?

Edit: What I'm really after is the NSWindow instance.

I've added an AppDelegate, but as I understand it the NSWindow is likely to be nil, so unavailable for modification, and simply creating one here similar to the AppKit App Delegate lifecycle method results in two windows appearing at launch.

One solution would be preventing the default window from appearing, and leaving it all to the applicationDidFinishLaunching method, but not sure this is possible or sensible.

The WindowStyle protocol looks to be a possible solution, but not sure how best to leverage that with a CustomWindowStyle at this stage, and whether that provides access to the NSWindow instance for fine-grained control.

class AppDelegate: NSObject, NSApplicationDelegate {        
    func applicationDidFinishLaunching(_ aNotification: Notification) {
      // In AppKit simply create the NSWindow and modify style.
      // In SwiftUI creating an NSWindow and styling results in 2 windows, 
      // one styled and the other default.
    }
}

@main
struct testApp: App {
    
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate : AppDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Upvotes: 12

Views: 7532

Answers (3)

Mark A. Donohoe
Mark A. Donohoe

Reputation: 30328

Tossing my hat into the ring. I implemented this functionality as a ViewModifier that both injects the NSWindow into the Environment as well as taking an optional handler so you can access the NSWindow directly.

Usage:

In its simplest form, you attach it to a view which automatically sets the nsWindow property in the environment.

ChildView()
.nsWindowMonitor()

And here's how you use it in any descendant views...

struct ChildView: View {

    @Environment(\.nsWindow) var nsWindow

    var body: some View {

        let message = nsWindow != nil
            ? "NSWindow set. WOOT!!!"
            : "NSWindow not set. Ratz!"

        Text(message)
    }
}

Additionally, you can specify an optional handler giving you immediate access to the NSWindow once it's available...

Text("Handler Example)
.nsWindowMonitor { nsWindow in
    // Attach 'palette' windows, modify the opacity, etc.
}

Implementation:

Here's the full implementation. This is ready to be put into a Swift package. If you don't need that, delete the #if, @available and public keywords to make it internal to your app/module.

#if canImport(SwiftUI)

import SwiftUI

public typealias NSWindowHandler = (NSWindow?) -> Void

@available(macOS 12, *)
public extension EnvironmentValues {
    @Entry var nsWindow: NSWindow?
}

@available(macOS 12, *)
public struct NSWindowMonitorViewModifier: ViewModifier {

    @State var nsWindow: NSWindow?
    let handler: NSWindowHandler?

    public func body(content: Content) -> some View {
        content
        .background {
            NSWindowMonitorView.Representable { nsWindow in
                self.nsWindow = nsWindow
                handler?(nsWindow)
            }
        }
        .environment(\.nsWindow, nsWindow)
    }
}

@available(macOS 12, *)
public extension View {

    func nsWindowMonitor(_ handler: NSWindowHandler? = nil) -> some View {
        self
        .modifier(NSWindowMonitorViewModifier(handler: handler))
    }
}

@available(macOS 12, *)
fileprivate class NSWindowMonitorView: NSView {

    struct Representable: NSViewRepresentable {

        let handler: NSWindowHandler

        func makeNSView(context: Context) -> NSView {
            NSWindowMonitorView(handler: handler)
        }

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

    let handler: NSWindowHandler

    init(handler: @escaping NSWindowHandler) {
        self.handler = handler
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ dirtyRect: NSRect) {}

    override func viewDidMoveToWindow() {
        handler(window)
    }
}

#endif

Upvotes: 0

Chris
Chris

Reputation: 8412

Interesting approach @hillmark.

I also got it working with an approach using NSApplicationDelegateAdaptor.

I believe the code below would only help you with a single window MacOS SwiftUI application - a multi window app would likely need to handle all the windows, rather than just take the first one.

I'm also not sure of any caveats of my approach yet.

For now this solves my issue precisely, but I'll check your approach out also.

import SwiftUI

final class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        if let window = NSApplication.shared.windows.first {
            window.titleVisibility = .hidden
            window.titlebarAppearsTransparent = true
            window.isOpaque = false
            window.backgroundColor = NSColor.clear
        }
    }
}

@main
struct AlreadyMacOSApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
    var body: some Scene {
        WindowGroup {
            NewLayoutTest()
        }
    }
}

Upvotes: 14

hillmark
hillmark

Reputation: 827

Although I am not entirely sure this is exactly the right approach, based on the answer to this question: https://stackoverflow.com/a/63439982/792406 I have been able to access the NSWindow instance and modify its appearance.

For quick reference, here's a working example based on the original code provided by Asperi using xcode 12.3, swift 5.3, and the SwiftUI App Life cycle.

@main
struct testApp: App {    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class Store {
    var window: NSWindow
    
    init(window: NSWindow) {
        self.window = window
        self.window.isOpaque = false
        self.window.backgroundColor = NSColor.clear
    }
}

struct ContentView: View {
    @State private var window: NSWindow?
    var body: some View {
        VStack {
            Text("Loading...")
            if nil != window {
                MainView(store: Store(window: window!))
            }
        }.background(WindowAccessor(window: $window))
    }
}

struct MainView: View {

    let store: Store
    
    var body: some View {
        VStack {
            Text("MainView with Window: \(store.window)")
        }.frame(width: 400, height: 400)
    }
}

struct WindowAccessor: NSViewRepresentable {
    @Binding var window: NSWindow?
    
    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async {
            self.window = view.window
        }
        return view
    }
    
    func updateNSView(_ nsView: NSView, context: Context) {}
}

Upvotes: 13

Related Questions