Skip to content

Hack to solve dynamic width and height #375

@eonist

Description

@eonist

The short answer is: not cleanly with stock NSPopover — but there are real workarounds. Here's the full picture:


The Core Problem

NSPopover re-anchors its position any time contentSize changes, even height-only. This is a hardcoded AppKit behavior with no public API override. You can't tell it "resize but keep the X position."


What Actually Works

Option 1 — Dynamic height only, fixed width (recommended)

This is what your codebase solved with idealWidth. Width is pinned, so the anchor never drifts. Height is fully dynamic.

swift
// AppDelegate hc.sizingOptions = .preferredContentSize // No manual contentSize anywhere // Root Group in PopoverView Group { ... } .frame(idealWidth: 340) // width pinned via ideal size // Content height varies freely — no jump

This gives you fully dynamic height and is stable. The constraint is that width must be fixed. For most status bar apps this is fine — popover width never needs to change.


Option 2 — Custom NSPanel instead of NSPopover

Skip NSPopover entirely and use a borderless NSPanel positioned manually under the status item. You draw your own arrow/bezel and position the window yourself. Width and height are then fully free.

swift
let panel = NSPanel( contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isFloatingPanel = true panel.level = .statusBar panel.backgroundColor = .clear panel.isOpaque = false // Position it manually under the status item button let buttonRect = statusItem.button!.window!.convertToScreen(statusItem.button!.frame) panel.setFrameTopLeftPoint(NSPoint(x: buttonRect.minX, y: buttonRect.minY))

You resize the panel at will with setFrame() — no re-anchoring because you own the position logic. Trade-off: you lose the native popover arrow, dismissal-on-outside-click, and accessibility behaviors. You have to reimplement them.


Option 3 — Only resize when the popover is closed

Change contentSize only in the moments between close and show — never while it's visible.

swift
func toggle() { if popover.isShown { popover.performClose(nil) } else { // Safe to set size here — popover is not yet visible let newSize = computeSize() popover.contentSize = newSize popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) } }

This gives you both dynamic width and height, but only updates size on open, not while the user is looking at it. For most use cases (the content is recalculated before showing) this is perfectly fine.


Option 4 — NSAnimationContext with duration: 0 (partial hack)

Some developers report that wrapping contentSize changes in a zero-duration animation group suppresses the re-anchor visually, though it's not guaranteed:

swift
NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0 popover.contentSize = newSize }

This is what your navigate() function already uses. It works for deliberate user-triggered navigation but still re-anchors if called during a live poll — it just happens fast enough to be invisible on user taps.


Verdict

Approach | Dynamic Width | Dynamic Height | Jump-free | Complexity -- | -- | -- | -- | -- idealWidth + preferredContentSize | ❌ (fixed) | ✅ | ✅ | Low Resize only on open (before show()) | ✅ | ✅ | ✅ | Low Custom NSPanel | ✅ | ✅ | ✅ | High Resize while visible | ✅ | ✅ | ❌ always jumps | N/A

For runner-bar specifically: the idealWidth approach already gives you everything you need since the width doesn't need to change. If you ever need dynamic width too, resize before show() is the cleanest zero-overhead solution.

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