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:
| Style | Description | Best For |
|---|---|---|
.unified | Controls next to inline title at leading edge | Modern apps, most windows |
.unifiedCompact | Smaller height, minimal controls | Utility apps, content-focused |
.expanded | Title above toolbar, labeled buttons | Document apps, heavy toolbars |
.preference | For settings/preferences windows | Preference panels |
.automatic | System determines based on window | Legacy 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 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
- 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)
}
} - 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")
}
} - 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
.unifiedCompactfor 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
.flexibleSpacecreates 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:
- Floating play button (existing)
- Menu bar commands (existing Cmd+N, Cmd+.)
- Settings via File menu (add if not present)
- 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])
}