WOODY'S
FINDINGS

Positioning a window in macOS

Tip

8 August 2021

Most often when working with windows on macOS, it’s not necessary to think about where the window is positioned. The user might move it or resize it after it has been opened. That said, I did face situations where putting the window at a specific position was needed. For example with panels and help windows that should remain at a specific place while the user is doing something else. In this article, we’ll see how to easily position a window horizontally and vertically. The two axis combination will allow us to get 3x3 = 9 different possibilities like « top-left », « center », or « bottom-center ». Lastly, a padding option should be available to detach the window from the edges.

The final code can be found on this gist. A function will be made available on NSWindow, and a SwiftUI modifier will be provided.

The Position model

I like to start by thinking about how it should be possible to use a feature I am implementing, then to start implementing the model. It would be neat if we could specify the window position like so:

// window: NSWindow
window.setPosition(vertical: .top, horizontal: .left, padding: 20)

Looking at it, it seems that the best tool for the vertical and horizontal positions is an enum. Then a struct could wrap them with the padding parameter.

Of course, there are several ways to model that. We could for instance stick to two enums and provide the logic directly in the setPosition(vertical:horizontal:padding:) function. But I like being able to pass the created position if necessary.

Alright, with the remarks above, let’s see what the Position type looks like.

extension NSWindow { // 1

	struct Position {
		// 2
		static let defaultPadding: CGFloat = 16
		// 3
		var vertical: Vertical
		var horizontal: Horizontal
		var padding = Self.defaultPadding
    }
}

Here are some remarks:

  1. To avoid cluttering the namespace, I prefer to define the Position structure inside the NSWindow type since it closely related to it.
  2. I think it could be useful to have a default padding from edges to make the padding property specification optional.
  3. The two axis enums will be defined inside the Position type for the same reason Position is defined inside NSWindow.

Here are the enums:

extension NSWindow.Position {

    enum Horizontal {
        case left, center, right
    }

    enum Vertical {
        case top, center, bottom
    }
}

So that’s it for the models! Now let’s think about the actual logic to compute a position.


Positioning logic

Naturally, the function to compute the position should return a CGPoint that will be used as the window’s origin property. AppKit axis starts in the bottom-left corner on the screen. Thus it’s easy to get the proper origin for the window for the bottom-left corner: we just have to add the padding and we’re good to go. But for the other positions, we’ll have to take the window’s size into account to make sure it's properly aligned. This should not be too hard to overcome though.

Separating the vertical and horizontal axis allows us to reason with each one as isolated. So let’s start with the vertical one.

extension NSWindow.Position.Vertical {

	func valueFor(
        screenRange: Range<CGFloat>,
        height: CGFloat,
        padding: CGFloat)
    -> CGFloat {
        switch self {
        case .top: return screenRange.upperBound - height - padding
        case .center: return (screenRange.upperBound + screenRange.lowerBound - height) / 2
        case .bottom: return screenRange.lowerBound + padding
       }
   }
}

Let’s take a look at the parameters:

Now for the cases:

Since the same computations go for the horizontal axis, they are omitted. We are now ready to implement the Position method that takes a window and a screen rectangles to compute the origin of the window.

extension NSWindow.Position {

	func value(forWindow windowRect: CGRect, inScreen screenRect: CGRect)
	-> CGPoint {
       let xPosition = horizontal.valueFor(
           screenRange: screenRect.minX..<screenRect.maxX,
           width: windowRect.width,
           padding: padding
        )

       let yPosition = vertical.valueFor(
           screenRange: screenRect.minY..<screenRect.maxY,
           height: windowRect.height,
           padding: padding
       )

       return CGPoint(x: xPosition, y: yPosition)
    }
}

Horizontal and vertical computations are similar. The screen range is provided by the min/max properties of the frame depending on the axis. Then we pass either the window’s width for the horizontal position and the height for the vertical position.


AppKit extensions

With this function implemented, it’s easy to define the function to set a NSWindow position.

extension NSWindow {

    func setPosition(_ position: Position, in screen: NSScreen?) {
        guard let visibleFrame = (screen ?? self.screen)?.visibleFrame else { return }
        let origin = position.value(forWindow: frame, inScreen: visibleFrame)
        setFrameOrigin(origin)
    }
}

This function takes a Position parameter, as well as an optional NSScreen to put the window on. If no screen is provided, it will be the actual screen the window is on. A quick note about the visibleFrame property. This is to take the menu bar and the dock (if not automatically hidden) into account. If we don’t consider it, we will work with the full screen frame although it is not available. Then we get the origin’s point from the position parameter and assign it to the window’s origin.

For convenience, another function is implemented.

func setPosition(
	vertical: Position.Vertical,
	horizontal: Position.Horizontal,
   padding: CGFloat = Position.defaultPadding,
	screen: NSScreen? = nil)
{
	set(
		position: Position(
			vertical: vertical,
			horizontal: horizontal,
			padding: padding),
		in: screen
	)
}

This way, we get the desired function to set a window position, for instance in the AppDelegate.

window.setPosition(vertical: .top, horizontal: .center)
// or
window.setPosition(vertical: .bottom, horizontal: .left, padding: 20)
// or
window.setPosition(vertical: .center, horizontal: .center, screen: .main)

All screens are accessible through the array NSScreen.screens

Pretty nice, don’t you think?

It’s ok to stop here for AppKit I think. But if you are interested into using this feature in SwiftUI, here’s what I came up with.


SwiftUI modifier

The main difficulty is that the window a SwiftUI view is in is not accessible yet in the Environment. My first attempt was to pass the primary window created in the AppDelegate to the environment. That’s fine but it requires to think about it and to work with the AppDelegate life cycle rather than the new @main entry point.

Then I read this post from LostMoa and it offers a good idea. We’ll make a NSViewRepresentable in the background of the SwiftUI view, and access the window property. This will be put inside a ViewModifier.

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

    func makeNSView(context: Self.Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async { self.callback(view.window) }
        return view
     }

    func updateNSView(_ nsView: NSView, context: Context) {
        DispatchQueue.main.async { self.callback(nsView.window) }
    }
}

private struct WindowPositionModifier: ViewModifier {

    let position: NSWindow.Position
    let screen: NSScreen?

    func body(content: Content) -> some View {
        content.background(
            HostingWindowFinder {
                $0?.setPosition(position, in: screen)
            }
        )
    }
}

The callback call in the updateNSView function is not required if you are only interested to position the window when the view is loaded. Otherwise, it’s necessary to call it here if it’s part of the view state since the function makeNSView is only called the the view is created for the first time.

With this in place, we add a custom function as advised by the documentation.

extension View {

	func hostingWindowPosition(
       vertical: NSWindow.Position.Vertical,
       horizontal: NSWindow.Position.Horizontal,
       padding: CGFloat = NSWindow.Position.defaultPadding,
       screen: NSScreen? = nil
   ) -> some View {
       modifier(
          WindowPositionModifier(
               position: NSWindow.Position(
                   vertical: vertical,
                   horizontal: horizontal,
                   padding: padding
               ),
               screen: screen
           )
       )
   }
}

We can now use it easily.

@main
struct MyAwesomeApp: App {

   var body: some Scene {
       WindowGroup {
           ContentView()
               .hostingWindowPosition(
                    vertical: .bottom,
                    horizontal: .left,
                    screen: .main
                )
        }
    }
}

📡

I hope you found this tip useful! If so, feel free to share it on Twitter. If you want to reach out to me or to know when new posts are available, you can find me on Twitter. Also you can send me an email.