Positioning a window in macOS
Tip8 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 enum
s 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:
- To avoid cluttering the namespace, I prefer to define the
Position
structure inside theNSWindow
type since it closely related to it. - I think it could be useful to have a default padding from edges to make the
padding
property specification optional. - The two axis
enum
s will be defined inside thePosition
type for the same reasonPosition
is defined insideNSWindow
.
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:
screenRange
: at my first very first attempt to position a window in a screen, I only used the screen size, so the height here. But then I realized that macOS would give aNSScreen
specific frame when several monitors are used. So we could have a second monitor which origin doesn’t start at (0, 0). It depends on how the screens layout is customized by the user. Thus, to ensure the window is properly set in the right screen, the screen axis bounds are passed.height
: that’s the window height. It’s used when the position is not at the toppadding
: when using padding, it has to be added to the computation of the origin.
Now for the cases:
bottom
: the easiest case. We add the padding to the screen origincenter
: the center of the screen is provided by(screenRange.upperBound - screenRange.lowerBound) / 2
. As we are setting the origin of the window, which is the bottom-left corner, removing half of the height is needed to center the overall window's height in the middle. Note that the padding is irrelevant here.top
: we simply remove the height of the window as well as the padding.
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.