Files
2025-11-29 18:28:37 +08:00

9.6 KiB

Menu Bar Apps

Status bar utilities with quick access and minimal UI.

<when_to_use> Use menu bar pattern when:

  • Quick actions or status display
  • Background functionality
  • Minimal persistent UI
  • System-level utilities

Examples: Rectangle, Bartender, system utilities </when_to_use>

<basic_setup>

import SwiftUI

@main
struct MenuBarApp: App {
    var body: some Scene {
        MenuBarExtra("MyApp", systemImage: "star.fill") {
            MenuContent()
        }
        .menuBarExtraStyle(.window)  // or .menu

        // Optional settings window
        Settings {
            SettingsView()
        }
    }
}

struct MenuContent: View {
    @AppStorage("isEnabled") private var isEnabled = true
    @Environment(\.openSettings) private var openSettings

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Enabled", isOn: $isEnabled)

            Divider()

            Button("Settings...") {
                openSettings()
            }
            .keyboardShortcut(",", modifiers: .command)

            Button("Quit") {
                NSApplication.shared.terminate(nil)
            }
            .keyboardShortcut("q", modifiers: .command)
        }
        .padding()
        .frame(width: 200)
    }
}

</basic_setup>

<menu_styles> <window_style> Rich UI with any SwiftUI content:

MenuBarExtra("MyApp", systemImage: "star.fill") {
    WindowStyleContent()
}
.menuBarExtraStyle(.window)

struct WindowStyleContent: View {
    var body: some View {
        VStack(spacing: 16) {
            // Header
            HStack {
                Image(systemName: "star.fill")
                    .font(.title)
                Text("MyApp")
                    .font(.headline)
            }

            Divider()

            // Content
            List {
                ForEach(items) { item in
                    ItemRow(item: item)
                }
            }
            .frame(height: 200)

            // Actions
            HStack {
                Button("Action 1") { }
                Button("Action 2") { }
            }
        }
        .padding()
        .frame(width: 300)
    }
}

</window_style>

<menu_style> Standard menu appearance:

MenuBarExtra("MyApp", systemImage: "star.fill") {
    Button("Action 1") { performAction1() }
        .keyboardShortcut("1")

    Button("Action 2") { performAction2() }
        .keyboardShortcut("2")

    Divider()

    Menu("Submenu") {
        Button("Sub-action 1") { }
        Button("Sub-action 2") { }
    }

    Divider()

    Button("Quit") {
        NSApplication.shared.terminate(nil)
    }
    .keyboardShortcut("q", modifiers: .command)
}
.menuBarExtraStyle(.menu)

</menu_style> </menu_styles>

<dynamic_icon>

@main
struct MenuBarApp: App {
    @State private var status: AppStatus = .idle

    var body: some Scene {
        MenuBarExtra {
            MenuContent(status: $status)
        } label: {
            switch status {
            case .idle:
                Image(systemName: "circle")
            case .active:
                Image(systemName: "circle.fill")
            case .error:
                Image(systemName: "exclamationmark.circle")
            }
        }
    }
}

enum AppStatus {
    case idle, active, error
}

// Or with text
MenuBarExtra {
    Content()
} label: {
    Label("\(count)", systemImage: "bell.fill")
}

</dynamic_icon>

<background_only> App without dock icon (menu bar only):

// Info.plist
// <key>LSUIElement</key>
// <true/>

@main
struct MenuBarApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        MenuBarExtra("MyApp", systemImage: "star.fill") {
            MenuContent()
        }

        Settings {
            SettingsView()
        }
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
        // Clicking dock icon (if visible) shows settings
        if !flag {
            NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
        }
        return true
    }
}

</background_only>

<global_shortcuts>

import Carbon

class ShortcutManager {
    static let shared = ShortcutManager()

    private var hotKeyRef: EventHotKeyRef?
    private var callback: (() -> Void)?

    func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
        self.callback = action

        var hotKeyID = EventHotKeyID()
        hotKeyID.signature = OSType("MYAP".fourCharCodeValue)
        hotKeyID.id = 1

        var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))

        InstallEventHandler(GetApplicationEventTarget(), { _, event, userData -> OSStatus in
            guard let userData = userData else { return OSStatus(eventNotHandledErr) }
            let manager = Unmanaged<ShortcutManager>.fromOpaque(userData).takeUnretainedValue()
            manager.callback?()
            return noErr
        }, 1, &eventType, Unmanaged.passUnretained(self).toOpaque(), nil)

        RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
    }

    func unregister() {
        if let ref = hotKeyRef {
            UnregisterEventHotKey(ref)
        }
    }
}

extension String {
    var fourCharCodeValue: FourCharCode {
        var result: FourCharCode = 0
        for char in utf8.prefix(4) {
            result = (result << 8) + FourCharCode(char)
        }
        return result
    }
}

// Usage
ShortcutManager.shared.register(
    keyCode: UInt32(kVK_ANSI_M),
    modifiers: UInt32(cmdKey | optionKey)
) {
    // Toggle menu bar app
}

</global_shortcuts>

<with_main_window> Menu bar app with optional main window:

@main
struct MenuBarApp: App {
    @State private var showMainWindow = false

    var body: some Scene {
        MenuBarExtra("MyApp", systemImage: "star.fill") {
            MenuContent(showMainWindow: $showMainWindow)
        }

        Window("MyApp", id: "main") {
            MainWindowContent()
        }
        .defaultSize(width: 600, height: 400)

        Settings {
            SettingsView()
        }
    }
}

struct MenuContent: View {
    @Binding var showMainWindow: Bool
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        VStack {
            Button("Show Window") {
                openWindow(id: "main")
            }

            // Quick actions...
        }
        .padding()
    }
}

</with_main_window>

<persistent_state>

struct MenuContent: View {
    @AppStorage("isEnabled") private var isEnabled = true
    @AppStorage("checkInterval") private var checkInterval = 60
    @AppStorage("notificationsEnabled") private var notifications = true

    var body: some View {
        VStack(alignment: .leading) {
            Toggle("Enabled", isOn: $isEnabled)

            Picker("Check every", selection: $checkInterval) {
                Text("1 min").tag(60)
                Text("5 min").tag(300)
                Text("15 min").tag(900)
            }

            Toggle("Notifications", isOn: $notifications)
        }
        .padding()
    }
}

</persistent_state>

<popover_from_menu_bar> Custom popover positioning:

class PopoverManager: NSObject {
    private var statusItem: NSStatusItem?
    private var popover = NSPopover()

    func setup() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

        if let button = statusItem?.button {
            button.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "MyApp")
            button.action = #selector(togglePopover)
            button.target = self
        }

        popover.contentViewController = NSHostingController(rootView: PopoverContent())
        popover.behavior = .transient
    }

    @objc func togglePopover() {
        if popover.isShown {
            popover.close()
        } else if let button = statusItem?.button {
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
        }
    }
}

</popover_from_menu_bar>

<timer_background_task>

@Observable
class BackgroundService {
    private var timer: Timer?
    var lastCheck: Date?
    var status: String = "Idle"

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
            Task {
                await self?.performCheck()
            }
        }
    }

    func stop() {
        timer?.invalidate()
        timer = nil
    }

    private func performCheck() async {
        status = "Checking..."
        // Do work
        await Task.sleep(for: .seconds(2))
        lastCheck = Date()
        status = "OK"
    }
}

struct MenuContent: View {
    @State private var service = BackgroundService()

    var body: some View {
        VStack {
            Text("Status: \(service.status)")

            if let lastCheck = service.lastCheck {
                Text("Last: \(lastCheck.formatted())")
                    .font(.caption)
            }

            Button("Check Now") {
                Task { await service.performCheck() }
            }
        }
        .padding()
        .onAppear {
            service.start()
        }
    }
}

</timer_background_task>

<best_practices>

  • Keep menu content minimal and fast
  • Use .window style for rich UI, .menu for simple actions
  • Provide keyboard shortcuts for common actions
  • Save state with @AppStorage
  • Include "Quit" option always
  • Use background-only (LSUIElement) when appropriate
  • Provide settings window for configuration
  • Show status in icon when possible (dynamic icon) </best_practices>