Nikolay Nikolaenko
Nikolay Nikolaenko

Reputation: 77

How set Position of window on the Desktop in SwiftUI?

How to set window coordinates in SwiftUI on MacOS Desktop? For example, should the window appear always in the center or always in the upper right corner?

Here is my version, however, I shift the code and close it, when I open it, it appears first in the old place, and then jumps to a new place.

import SwiftUI

let WIDTH: CGFloat = 400
let HEIGTH: CGFloat = 200

@main
struct ForVSCode_MacOSApp: App {
    @State var window : NSWindow?
    
    var body: some Scene {
        WindowGroup {
            ContentView(win: $window)
        }
    }
}

struct WindowAccessor: NSViewRepresentable{
    @Binding var window: NSWindow?
    
    func makeNSView(context: Context) -> some NSView {
        let view = NSView()
        
        let width = (NSScreen.main?.frame.width)!
        let heigth = (NSScreen.main?.frame.height)!
        
        let resWidth: CGFloat = (width / 2) - (WIDTH / 2)
        let resHeigt: CGFloat = (heigth / 2) - (HEIGTH / 2)
        
        DispatchQueue.main.async {
            self.window = view.window
            self.window?.setFrameOrigin(NSPoint(x: resWidth, y: resHeigt))
            self.window?.setFrameAutosaveName("mainWindow")
            self.window?.isReleasedWhenClosed = false
            self.window?.makeKeyAndOrderFront(nil)

        }
        return view
    }
    
    func updateNSView(_ nsView: NSViewType, context: Context) {
        
    }
}

and ContentView

import SwiftUI

struct ContentView: View {

    @Binding var win: NSWindow?
    var body: some View {
        VStack {
            Text("it finally works!")
        }
        .font(.largeTitle)
        .frame(width: WIDTH, height: HEIGTH, alignment: .center)
        .background(WindowAccessor(window: $win))

    }

}
struct ContentView_Previews: PreviewProvider {
    @Binding var win: NSWindow?

    static var previews: some View {
        ContentView(win: .constant(NSWindow()))
        .frame(width: 250, height: 150, alignment: .center)

    }
}

Upvotes: 4

Views: 4246

Answers (1)

pd95
pd95

Reputation: 2622

I do have the same issue in one of my projects and thought I will investigate a bit deeper and I found two approaches to control the window position.


So my first approach to influence the window position is by pre-defining the windows last position on screen.

Indirect control: Frame autosave name

When the first window of an app is opened, macOS will try to restore the last window position when it was last closed. To distinguish the different windows, each window has its own frameAutosaveName. The windows frame is persisted automatically in a text format in the apps preferences (UserDefaults.standard) with the key derived from the frameAutosaveName: "NSWindow Frame <frameAutosaveName>" (see docs for saveFrame).

If you do not specify an ID in your WindowGroup, SwiftUI will derive the autosave name from your main views class name. The first three windows will have the following autosave names:

 <ModuleName>.ContentView-1-AppWindow-1
 <ModuleName>.ContentView-1-AppWindow-2
 <ModuleName>.ContentView-1-AppWindow-3

By setting an ID for example WindowGroup(id: "main"), the following autosave names are used (again for the first three windows):

main-AppWindow-1
main-AppWindow-2
main-AppWindow-3

When you check in your apps preferences directory (where UserDefaults.standard is stored), you will see in the plist one entry:

NSWindow Frame main-AppWindow-1          1304 545 400 228 0 0 3008 1228

There are a lot of numbers to digest. The first 4 integers describe the windows frame (origin and size), the next 4 integers describe the screens frame.

There are a few things to keep in mind when manually setting those value:

  1. macOS coordinate system has it origin (0,0) in the bottom left corner.
  2. the windows height includes the window title bar (28px on macOS Monterey but may be different on other versions)
  3. the screens height excludes the title bar
  4. I don't have documentation on this format and used trial and error to gain knowledge about it...

So to fake the initial position in the center of the screen I used the following function which I run in the apps (or the ContentView) initializer. But keep in mind: with this method only the first window will be centered. All the following windows are going to be put down and right of the previous window.

func fakeWindowPositionPreferences() {
    let main = NSScreen.main!

    let screenWidth = main.frame.width
    let screenHeightWithoutMenuBar = main.frame.height - 25 // menu bar
    let visibleFrame = main.visibleFrame

    let contentWidth = WIDTH
    let contentHeight = HEIGHT + 28 // window title bar

    let windowX = visibleFrame.midX - contentWidth/2
    let windowY = visibleFrame.midY - contentHeight/2

    let newFramePreference = "\(Int(windowX)) \(Int(windowY)) \(Int(contentWidth)) \(Int(contentHeight)) 0 0 \(Int(screenWidth)) \(Int(screenHeightWithoutMenuBar))"
    UserDefaults.standard.set(newFramePreference, forKey: "NSWindow Frame main-AppWindow-1")
}

My second approach is by directly manipulating the underlying NSWindow similar to your WindowAccessor.

Direct control: Manipulating NSWindow

Your implementation of WindowAccessor has a specific flaw: Your block which is reading view.window to extract the NSWindow instance is run asynchronously: some time in the future (due to DispatchQueue.main.async).
This is why the window appears on screen on the SwiftUI configured position, then disappears again to finally move to your desired location. You need more control, which involves first monitoring the NSView to get informed as soon as possible when the window property is set and then monitoring the NSWindow instance to get to know when the view is becoming visible.

I'm using the following implementation of WindowAccessor. It takes a onChange callback closure which is called whenever window is changing. First it starts monitoring the NSViews window property to get informed when the view is added to a window. When this happened, it starts listening for NSWindow.willCloseNotification notifications to detect when the window is closing. At this point it will stop any monitoring to avoid leaking memory.

import SwiftUI
import Combine

struct WindowAccessor: NSViewRepresentable {
    let onChange: (NSWindow?) -> Void

    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        context.coordinator.monitorView(view)
        return view
    }

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

    func makeCoordinator() -> WindowMonitor {
        WindowMonitor(onChange)
    }

    class WindowMonitor: NSObject {
        private var cancellables = Set<AnyCancellable>()
        private var onChange: (NSWindow?) -> Void

        init(_ onChange: @escaping (NSWindow?) -> Void) {
            self.onChange = onChange
        }

        /// This function uses KVO to observe the `window` property of `view` and calls `onChange()`
        func monitorView(_ view: NSView) {
            view.publisher(for: \.window)
                .removeDuplicates()
                .dropFirst()
                .sink { [weak self] newWindow in
                    guard let self = self else { return }
                    self.onChange(newWindow)
                    if let newWindow = newWindow {
                        self.monitorClosing(of: newWindow)
                    }
                }
                .store(in: &cancellables)
        }

        /// This function uses notifications to track closing of `window`
        private func monitorClosing(of window: NSWindow) {
            NotificationCenter.default
                .publisher(for: NSWindow.willCloseNotification, object: window)
                .sink { [weak self] notification in
                    guard let self = self else { return }
                    self.onChange(nil)
                    self.cancellables.removeAll()
                }
                .store(in: &cancellables)
        }
    }
}

This implementation can then be used to get a handle to NSWindow as soon as possible. The issue we still face: we don't have full control of the window. We are just monitoring what happens and can interact with the NSWindow instance. This means: we can set the position, but we don't know exactly at which instant this should happen. E.g. setting the windows frame directly after the view has been added to the window, will have no impact as SwiftUI is first doing layout calculations to decide afterwards where it will place the window.
After some fiddling around, I started tracking the NSWindow.isVisible property. This allows me to set the position whenever the window becomes visible. Using above WindowAccessor my ContentView implementation looks as follows:

import SwiftUI
import Combine

let WIDTH: CGFloat = 400
let HEIGHT: CGFloat = 200

struct ContentView: View {
    @State var window : NSWindow?
    @State private var cancellables = Set<AnyCancellable>()

    var body: some View {
        VStack {
            Text("it finally works!")
                .font(.largeTitle)

            Text(window?.frameAutosaveName ?? "-")
        }
        .frame(width: WIDTH, height: HEIGHT, alignment: .center)
        .background(WindowAccessor { newWindow in
            if let newWindow = newWindow {
                monitorVisibility(window: newWindow)

            } else {
                // window closed: release all references
                self.window = nil
                self.cancellables.removeAll()
            }
        })
    }

    private func monitorVisibility(window: NSWindow) {
        window.publisher(for: \.isVisible)
            .dropFirst()  // we know: the first value is not interesting
            .sink(receiveValue: { isVisible in
                if isVisible {
                    self.window = window
                    placeWindow(window)
                }
            })
            .store(in: &cancellables)
    }

    private func placeWindow(_ window: NSWindow) {
        let main = NSScreen.main!
        let visibleFrame = main.visibleFrame
        let windowSize = window.frame.size

        let windowX = visibleFrame.midX - windowSize.width/2
        let windowY = visibleFrame.midY - windowSize.height/2

        let desiredOrigin = CGPoint(x: windowX, y: windowY)
        window.setFrameOrigin(desiredOrigin)
    }
}

I hope this solution helps others who want to get more control to the window in SwiftUI.

Upvotes: 6

Related Questions