Skip to content

Recommendation to avoid side jumping app issues #376

@eonist

Description

@eonist

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions