Full Recommendation: Preventing Popover Side-Jump in NSStatusItem Apps
Root Cause
NSPopover re-anchors its arrow horizontally whenever contentSize.width changes. If you pass a non-constant width — even once — the popover shifts left or right to re-centre its arrow on the status bar button. This is the side-jump.
The source of the non-constant width is NSHostingController.view.fittingSize.width. When any SwiftUI view in the tree uses .frame(maxHeight: .infinity), the layout engine returns an unstable width from fittingSize depending on which run-loop turn you sample it. You cannot reliably use it to drive contentSize.width.
The Rule (Corrected)
Pin NSPopover.contentSize.width and setFrameSize width to a single constant. Let SwiftUI handle all child layout dynamically inside that fixed boundary.
AppKit sizing calls → always Self.fixedWidth (one constant, never fittingSize.width)
SwiftUI layout → fully dynamic (Spacer, maxWidth: .infinity, HStack, etc.)
fittingSize.height → safe to read dynamically (height doesn't shift the anchor)
fittingSize.width → never read back into any sizing call
AppDelegate Rules
1. Declare one fixed-width constant
private static let fixedWidth: CGFloat = 480
This must match idealWidth in your root SwiftUI view. Change one, change both.
2. openPopover() — always use fixedWidth for width
private func openPopover() {
let size = NSSize(
width: Self.fixedWidth, // ← never fittingSize.width
height: hostingController.view.fittingSize.height
)
hostingController.view.setFrameSize(size)
popover.contentSize = size
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
}
3. remeasurePopover() — same rule
private func remeasurePopover() {
let newHeight = hostingController.view.fittingSize.height
guard newHeight > 0 else { return }
let newSize = NSSize(width: Self.fixedWidth, height: newHeight) // ← never fittingSize.width
hostingController.view.setFrameSize(newSize)
popover.contentSize = newSize
}
4. navigate() — resize asynchronously, never synchronously
private func navigate(to view: AnyView) {
hostingController?.rootView = view
guard popover?.isShown == true else { return }
DispatchQueue.main.async { [weak self] in
self?.remeasurePopover()
}
}
5. For async-loaded content (e.g. log views) — use two async hops
onLogLoaded: {
DispatchQueue.main.async { // hop 1: SwiftUI commits new content
DispatchQueue.main.async { // hop 2: SwiftUI finishes layout
self.remeasurePopover() // now fittingSize.height is stable
}
}
}
One hop is not enough — fittingSize still reflects the spinner on the first turn after isLoading flips.
6. Never use sizingOptions = .preferredContentSize
This delegates width control to SwiftUI, which re-introduces the non-determinism you're trying to avoid.
SwiftUI View Rules
Root view must use idealWidth, not fixed width
// ✅ Correct
.frame(idealWidth: 480, maxWidth: .infinity, alignment: .top)
// ❌ Wrong — prevents dynamic height measurement
.frame(width: 480)
idealWidth tells SwiftUI "lay out at 480pt" so fittingSize.height is measured at the right width. maxWidth: .infinity is required so the view fills the frame rather than collapsing.
All child views: fully dynamic — no restrictions
// ✅ All of this is fine inside the 480pt container
HStack {
Text(title).layoutPriority(1)
Spacer()
Text(status).frame(width: 44) // fixed widths on leaf elements are fine too
}
.padding(.horizontal, 12)
Never add .frame(maxHeight:) to your root ScrollView
Height must be driven entirely by fittingSize.height in remeasurePopover(). Capping the ScrollView height here clips content and mis-sizes the popover.
What Is and Isn't Hardcoded
| Must be constant |
Can be fully dynamic |
NSPopover.contentSize.width |
Every SwiftUI child view layout |
setFrameSize width |
HStack, Spacer, padding, Text |
AppDelegate.fixedWidth |
ScrollView content height |
PopoverMainView .frame(idealWidth:) |
fittingSize.height (used for vertical sizing) |
The user never sees the fixedWidth constant — it just sets the canvas. Everything rendered inside it is fully responsive.
Full Recommendation: Preventing Popover Side-Jump in NSStatusItem Apps
Root Cause
NSPopoverre-anchors its arrow horizontally whenevercontentSize.widthchanges. If you pass a non-constant width — even once — the popover shifts left or right to re-centre its arrow on the status bar button. This is the side-jump.The source of the non-constant width is
NSHostingController.view.fittingSize.width. When any SwiftUI view in the tree uses.frame(maxHeight: .infinity), the layout engine returns an unstable width fromfittingSizedepending on which run-loop turn you sample it. You cannot reliably use it to drivecontentSize.width.The Rule (Corrected)
AppDelegate Rules
1. Declare one fixed-width constant
This must match
idealWidthin your root SwiftUI view. Change one, change both.2.
openPopover()— always usefixedWidthfor width3.
remeasurePopover()— same rule4.
navigate()— resize asynchronously, never synchronously5. For async-loaded content (e.g. log views) — use two async hops
One hop is not enough —
fittingSizestill reflects the spinner on the first turn afterisLoadingflips.6. Never use
sizingOptions = .preferredContentSizeThis delegates width control to SwiftUI, which re-introduces the non-determinism you're trying to avoid.
SwiftUI View Rules
Root view must use
idealWidth, not fixedwidthidealWidthtells SwiftUI "lay out at 480pt" sofittingSize.heightis measured at the right width.maxWidth: .infinityis required so the view fills the frame rather than collapsing.All child views: fully dynamic — no restrictions
Never add
.frame(maxHeight:)to your rootScrollViewHeight must be driven entirely by
fittingSize.heightinremeasurePopover(). Capping the ScrollView height here clips content and mis-sizes the popover.What Is and Isn't Hardcoded
NSPopover.contentSize.widthsetFrameSizewidthHStack,Spacer,padding,TextAppDelegate.fixedWidthScrollViewcontent heightPopoverMainView .frame(idealWidth:)fittingSize.height(used for vertical sizing)The user never sees the
fixedWidthconstant — it just sets the canvas. Everything rendered inside it is fully responsive.