Mac Catalyst Title Bar Design Options

Analysis and recommendations for improving the Minuta Mac Catalyst window header.

Current State

// SceneDelegate.swift
windowScene.titlebar?.titleVisibility = .hidden
windowScene.titlebar?.toolbar = nil

Current issues:

  • Title bar completely hidden, leaving only traffic lights
  • Pin button placed in SwiftUI .toolbar (rendered as navigation bar)
  • No native macOS toolbar integration
  • Misses opportunity for better Mac UX

Available Toolbar Styles

macOS offers five toolbar styles via NSWindow.toolbarStyle:

StyleDescriptionBest For
.unifiedControls next to inline title at leading edgeModern apps, most windows
.unifiedCompactSmaller height, minimal controlsUtility apps, content-focused
.expandedTitle above toolbar, labeled buttonsDocument apps, heavy toolbars
.preferenceFor settings/preferences windowsPreference panels
.automaticSystem determines based on windowLegacy compatibility

For Minuta (utility/timer app): .unifiedCompact is recommended.

Design Options

Option A: Native NSToolbar (Recommended)

Implement proper NSToolbar with native Mac controls:

class SceneDelegate: NSObject, UIWindowSceneDelegate, NSToolbarDelegate {
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }

        let toolbar = NSToolbar(identifier: "MinutaToolbar")
        toolbar.delegate = self
        toolbar.displayMode = .iconOnly

        windowScene.titlebar?.toolbar = toolbar
        windowScene.titlebar?.toolbarStyle = .unifiedCompact
        windowScene.titlebar?.titleVisibility = .hidden
    }

    // MARK: - NSToolbarDelegate

    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        [.flexibleSpace, pinButtonIdentifier, settingsIdentifier]
    }

    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        toolbarDefaultItemIdentifiers(toolbar)
    }

    func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
        // Create toolbar items
    }
}

Pros:

  • Native Mac look and feel
  • Proper window dragging behavior
  • Items stay in title bar area
  • Better keyboard accessibility

Cons:

  • More complex setup
  • Requires NSToolbarDelegate implementation
  • Communication between NSToolbar and SwiftUI state

Option B: Inline Title with SwiftUI Toolbar

Keep using SwiftUI toolbar but configure titlebar properly:

// SceneDelegate
windowScene.titlebar?.titleVisibility = .visible // or .hidden
windowScene.titlebar?.toolbarStyle = .unifiedCompact
windowScene.titlebar?.toolbar = nil // Let SwiftUI manage via .toolbar

// ContentView
.toolbar {
    ToolbarItem(placement: .primaryAction) {
        // Pin button
    }
}
.toolbarRole(.navigationStack) // iOS 16+

Pros:

  • Simpler implementation
  • Uses existing SwiftUI code
  • Single source of truth for state

Cons:

  • Less control over appearance
  • May not look as native on Mac
  • Navigation bar hosting can cause issues

Option C: Custom UIView in Title Bar

Use Steven Troughton-Smith’s technique for custom title bar content:

windowScene.titlebar?.titleVisibility = .hidden
// Use undocumented value to enable custom content

See: CatalystCustomToolbar

Pros:

  • Full SwiftUI/UIKit control
  • Maximum flexibility

Cons:

  • Uses undocumented API
  • May break in future macOS versions
  • Complex draggability handling

Option D: Borderless Window (SwiftUI)

For a more unique utility appearance (requires macOS 15+):

WindowGroup {
    ContentView()
}
.windowStyle(.plain)
.toolbar(removing: .title)
.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)
.containerBackground(.thickMaterial, for: .window)

Pros:

  • Modern, distinctive look
  • Full-bleed content
  • Great for utility/overlay apps

Cons:

  • Requires newer macOS
  • Loses standard window chrome
  • May confuse users

Recommended Implementation

For Minuta, a time tracking utility app, Option A with .unifiedCompact provides the best balance:

Layout Recommendation

[Traffic Lights] ----flexible space---- [Pin] [Settings]

Implementation Outline

  1. SceneDelegate.swift - Setup NSToolbar:
class SceneDelegate: NSObject, UIWindowSceneDelegate, NSToolbarDelegate {
    private let pinButtonIdentifier = NSToolbarItem.Identifier("pinButton")
    private let settingsIdentifier = NSToolbarItem.Identifier("settings")

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }

        let toolbar = NSToolbar(identifier: "MinutaToolbar")
        toolbar.delegate = self
        toolbar.displayMode = .iconOnly
        toolbar.allowsUserCustomization = false

        windowScene.titlebar?.toolbar = toolbar
        windowScene.titlebar?.toolbarStyle = .unifiedCompact
        windowScene.titlebar?.titleVisibility = .hidden
    }

    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        [.flexibleSpace, pinButtonIdentifier, settingsIdentifier]
    }

    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        toolbarDefaultItemIdentifiers(toolbar)
    }

    func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
        switch itemIdentifier {
        case pinButtonIdentifier:
            let item = NSToolbarItem(itemIdentifier: itemIdentifier)
            item.image = NSImage(systemSymbolName: "pin", accessibilityDescription: "Pin")
            item.label = "Pin"
            item.toolTip = "Keep window on top"
            item.target = self
            item.action = #selector(togglePin)
            return item

        case settingsIdentifier:
            let item = NSToolbarItem(itemIdentifier: itemIdentifier)
            item.image = NSImage(systemSymbolName: "gear", accessibilityDescription: "Settings")
            item.label = "Settings"
            item.toolTip = "Open settings"
            item.target = self
            item.action = #selector(openSettings)
            return item

        default:
            return nil
        }
    }

    @objc private func togglePin() {
        WindowPinManager.shared.toggle()
        // Update button image
    }

    @objc private func openSettings() {
        NotificationCenter.default.post(name: .openSettings, object: nil)
    }
}
  1. Update button state when pin changes:
// Observe pin state and update toolbar button image
func updatePinButtonImage() {
    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
          let toolbar = windowScene.titlebar?.toolbar else { return }

    if let item = toolbar.items.first(where: { $0.itemIdentifier == pinButtonIdentifier }) {
        let imageName = WindowPinManager.shared.isPinned ? "pin.fill" : "pin"
        item.image = NSImage(systemSymbolName: imageName, accessibilityDescription: "Pin")
    }
}
  1. Remove SwiftUI toolbar buttons from ContentView (only on Mac):
.toolbar {
    #if !targetEnvironment(macCatalyst)
    ToolbarItem(placement: .topBarTrailing) {
        // iOS-only toolbar items
    }
    #endif
}

Design Considerations

Apple HIG Recommendations

From Apple Human Interface Guidelines:

  • Leading end: Navigation, sidebar toggles, document title (not customizable)
  • Center: Frequently used actions (customizable)
  • Trailing end: Important persistent items, inspectors, search (not customizable)

For utility apps like Minuta:

  • Keep toolbar minimal
  • Use .unifiedCompact for smaller footprint
  • Place most-used controls at trailing edge
  • Consider hiding title for cleaner look

Draggability

The title bar area should remain draggable. With NSToolbar:

  • Space between items is automatically draggable
  • .flexibleSpace creates large draggable area
  • No special handling needed

Dark Mode

NSToolbar automatically adapts to system appearance. No special handling required.

Alternative: Menu Bar Only

For ultimate minimalism, consider removing window toolbar entirely and relying on:

  1. Floating play button (existing)
  2. Menu bar commands (existing Cmd+N, Cmd+.)
  3. Settings via File menu (add if not present)
  4. Pin via Window menu (standard macOS pattern)
// In Commands
CommandGroup(after: .windowArrangement) {
    Button(WindowPinManager.shared.isPinned ? "Unpin Window" : "Pin Window") {
        WindowPinManager.shared.toggle()
    }
    .keyboardShortcut("p", modifiers: [.command, .shift])
}

Sources