5.11 — SwiftUI macOS advanced

Opening scenario

Your SwiftUI multiplatform notes app works fine on Mac, but Mac users complain:

  • “There’s no menu bar icon to quick-create a note”
  • “Inspector pane doesn’t toggle with the standard ⌥⌘I shortcut”
  • “I want a floating window with all my favorites”
  • “The toolbar items don’t show labels in ‘Icon and Text’ mode”
  • “Why can’t I right-click a note for actions?”
  • “Where’s the dock menu?”

Mac users have higher expectations than iPhone users for UI conventions. The Mac has a 40-year history of standards: menu bar items, keyboard shortcuts for everything, customizable toolbars, dock menus, status items, services. SwiftUI provides primitives for most of this; for the rest, drop into AppKit interop.

AffordanceAPI
Menu bar status itemMenuBarExtra scene
Dock menuApp.commands { ... } or NSApp.dockMenu
Floating/auxiliary windowUtilityWindow, custom window controller
Inspector pane.inspector(isPresented:) modifier (iOS 17+/macOS 14+)
Keyboard shortcuts.keyboardShortcut(_:modifiers:)
Right-click menu.contextMenu { ... }
Toolbar with customizationToolbar + ToolbarItem(customizationID:)
Native NSViewNSViewRepresentable
URL handling.handlesExternalEvents(...), .onOpenURL

Concept → Why → How → Code

@main
struct QuickNotesApp: App {
    @State private var store = NoteStore()

    var body: some Scene {
        WindowGroup { ContentView().environment(store) }

        MenuBarExtra("Quick Notes", systemImage: "note.text") {
            QuickNotesMenu(store: store)
        }
        .menuBarExtraStyle(.window)   // or .menu
    }
}

struct QuickNotesMenu: View {
    let store: NoteStore

    var body: some View {
        VStack(alignment: .leading) {
            ForEach(store.favorites) { note in
                Button(note.title) { open(note) }
            }
            Divider()
            Button("New Note") { store.createNew() }
                .keyboardShortcut("n", modifiers: [.command, .shift])
            Divider()
            Button("Quit") { NSApplication.shared.terminate(nil) }
                .keyboardShortcut("q", modifiers: .command)
        }
        .padding()
        .frame(width: 240)
    }
}
  • MenuBarExtra is its own Scene
  • .menuBarExtraStyle(.menu): traditional dropdown menu of items
  • .menuBarExtraStyle(.window): opens a custom view (like Apple’s Control Center popups)
  • Works alongside WindowGroup — both coexist

For menu-bar-only apps (no Dock icon), add LSUIElement = true in Info.plist; ship just the MenuBarExtra scene.

Toolbar on macOS

NavigationStack {
    NoteEditor(note: $note)
        .navigationTitle(note.title)
        .toolbar(id: "editor") {
            ToolbarItem(id: "bold", placement: .primaryAction) {
                Button(action: toggleBold) {
                    Label("Bold", systemImage: "bold")
                }
            }
            ToolbarItem(id: "italic", placement: .primaryAction) {
                Button(action: toggleItalic) {
                    Label("Italic", systemImage: "italic")
                }
            }
            ToolbarItem(id: "spacer", placement: .primaryAction) {
                Spacer()
            }
            ToolbarItem(id: "share", placement: .primaryAction) {
                ShareLink(item: note.text)
            }
        }
        .toolbarTitleDisplayMode(.inline)
}
  • .toolbar(id:) enables user customization (drag-and-drop reorder, show/hide)
  • ToolbarItem(id:placement:) — id makes them customizable
  • placement: .primaryAction puts in the window toolbar (Mac)
  • Label("Name", systemImage: "icon") — Mac users can show both, icon-only, or text-only via toolbar customization
  • ToolbarItemGroup for related groups

.inspector(isPresented:) — right-side pane

struct ContentView: View {
    @State private var showInspector = true
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            Sidebar(selection: $selectedNote)
        } detail: {
            if let note = selectedNote {
                NoteEditor(note: note)
                    .inspector(isPresented: $showInspector) {
                        InspectorPane(note: note)
                            .inspectorColumnWidth(min: 220, ideal: 280, max: 400)
                            .toolbar {
                                Button {
                                    showInspector.toggle()
                                } label: {
                                    Label("Toggle Inspector", systemImage: "sidebar.right")
                                }
                                .keyboardShortcut("i", modifiers: [.command, .option])
                            }
                    }
            }
        }
    }
}

.inspector (iOS 17+ / macOS 14+) provides the standard right-side inspector. Resizable on Mac, modal-like on iPad portrait.

Window and UtilityWindow

@main
struct AppName: App {
    var body: some Scene {
        WindowGroup { MainView() }

        Window("About", id: "about") {
            AboutView()
                .frame(width: 360, height: 220)
        }
        .windowResizability(.contentSize)
        .windowStyle(.hiddenTitleBar)

        UtilityWindow("Calculator", id: "calc") {
            CalculatorView()
        }
        .keyboardShortcut("c", modifiers: [.command, .option])
    }
}
  • Window: single-instance window (calling openWindow(id:) again brings it forward)
  • UtilityWindow: floats above main windows, smaller title bar (palette-style)
  • .windowStyle(.hiddenTitleBar): no title bar, custom background
  • .windowResizability(.contentSize): locked to content size

Window styling

WindowGroup { MainView() }
    .windowStyle(.titleBar)            // default
    .windowStyle(.hiddenTitleBar)
    .windowStyle(.plain)
    .windowToolbarStyle(.unified)      // toolbar merged with title bar
    .windowToolbarStyle(.unifiedCompact)
    .windowToolbarStyle(.expanded)

.windowToolbarStyle(.unified) is the modern look — title and toolbar in one row.

Dock menu

@main
struct App: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .commands {
                CommandGroup(replacing: .appInfo) {
                    Button("About App") { showAbout = true }
                }
            }
    }
}

For a true dock menu (right-click the dock icon), use NSApplicationDelegate:

class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
        let menu = NSMenu()
        menu.addItem(withTitle: "New Note", action: #selector(newNote), keyEquivalent: "n")
        return menu
    }
}

@main
struct App: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
    var body: some Scene { /* ... */ }
}

@NSApplicationDelegateAdaptor adopts an NSApplicationDelegate into a SwiftUI app — for the corners SwiftUI doesn’t cover.

NSViewRepresentable — wrap AppKit views

Same pattern as UIViewRepresentable:

struct ColorPickerWell: NSViewRepresentable {
    @Binding var color: Color

    func makeNSView(context: Context) -> NSColorWell {
        let well = NSColorWell()
        well.target = context.coordinator
        well.action = #selector(Coordinator.colorChanged(_:))
        return well
    }

    func updateNSView(_ nsView: NSColorWell, context: Context) {
        nsView.color = NSColor(color)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    final class Coordinator: NSObject {
        var parent: ColorPickerWell
        init(_ parent: ColorPickerWell) { self.parent = parent }

        @objc func colorChanged(_ sender: NSColorWell) {
            parent.color = Color(sender.color)
        }
    }
}

Use for: NSTextView (full-featured rich text editing), NSTableView (when Table doesn’t fit), MTKView (Metal rendering), WKWebView (or use the SwiftUI WebView in newer SDKs), 3rd-party AppKit controls.

NSHostingController — SwiftUI inside AppKit

let host = NSHostingController(rootView: ContentView().environment(store))
window.contentViewController = host

For mixed AppKit apps (existing Mac codebase adding SwiftUI features).

Right-click context menus

List(notes) { note in
    Text(note.title)
        .contextMenu {
            Button("Open") { open(note) }
            Button("Open in New Window") { openWindow(id: "note", value: note.id) }
            Divider()
            Button("Toggle Favorite") { note.isFavorite.toggle() }
            Divider()
            Button("Delete", role: .destructive) { delete(note) }
        }
}

.contextMenu works on iOS (long press) and Mac (right-click) with the same code. Use it everywhere — Mac users expect right-click on anything.

For dynamic content + preview:

.contextMenu {
    Button("Open") { ... }
    Button("Share") { ... }
} preview: {
    NotePreview(note: note)   // iOS shows; Mac ignores preview
}

Keyboard shortcuts

Button("Save") { save() }
    .keyboardShortcut("s", modifiers: .command)

Button("Refresh") { refresh() }
    .keyboardShortcut(.return, modifiers: [.command, .shift])

Button("Escape") { dismiss() }
    .keyboardShortcut(.escape)

In Commands, shortcuts appear in menus. Without Commands, shortcuts still work when the view is in the responder chain.

KeyboardShortcut.standardEdit patterns

Apple-conventional shortcuts:

ShortcutAction
⌘NNew
⌘OOpen
⌘SSave
⌘WClose window
⌘QQuit
⌘,Settings
⌘ZUndo
⇧⌘ZRedo
⌘X/C/VCut/Copy/Paste
⌘FFind
⌘PPrint
⌘+/⌘-Zoom
⌥⌘SSave as / duplicate
⌥⌘1/2/3View modes
⌥⌘IShow inspector

Match these. Mac users have them in muscle memory.

focusedSceneValue and @FocusedValue

The mechanism for “what’s the focused window/view, and what actions does it offer?”:

extension FocusedValues {
    @Entry var selectedNote: Note?
    @Entry var noteActions: NoteActions?
}

struct NoteActions {
    var toggleFavorite: () -> Void
    var rename: () -> Void
}

// In a view:
NoteEditor(note: note)
    .focusedSceneValue(\.selectedNote, note)
    .focusedSceneValue(\.noteActions, NoteActions(
        toggleFavorite: { note.isFavorite.toggle() },
        rename: { startRenaming() }
    ))

// In commands:
.commands {
    CommandMenu("Note") {
        Button("Toggle Favorite") {
            actions?.toggleFavorite()
        }
        .keyboardShortcut("f", modifiers: [.command, .shift])
        .disabled(actions == nil)
    }
}

struct NoteCommands: Commands {
    @FocusedValue(\.noteActions) var actions: NoteActions?

    var body: some Commands {
        CommandMenu("Note") { /* as above */ }
    }
}

The menu items enable when a view publishes the action. The pattern that makes Mac menus feel native.

For utilities that live in the menu bar (no Dock icon, no main window):

  • Info.plist: LSUIElement = YES
  • App scene contains only MenuBarExtra
  • Pure status item
@main
struct AccessoryApp: App {
    var body: some Scene {
        MenuBarExtra("Status", systemImage: "wifi") {
            StatusView()
        }
        .menuBarExtraStyle(.window)
    }
}
ContentView()
    .onOpenURL { url in
        handleDeepLink(url)
    }
    .handlesExternalEvents(matching: ["myapp"])

Mac: register URL schemes in Info.plist (CFBundleURLTypes). Same as iOS.

For File handling (drag-drop, double-click in Finder):

DocumentGroup(viewing: NoteDocument.self) { config in
    NoteEditor(document: config.document)
}

Drag and drop

List(notes) { note in
    Text(note.title)
        .draggable(note)
}
.dropDestination(for: Note.self) { items, _ in
    items.forEach { store.add($0) }
    return true
}

Transferable protocol (iOS 16+/macOS 13+) — define how your type encodes for drag-drop:

extension Note: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .text)
        ProxyRepresentation(exporting: \.text)
    }
}

Same code works for share sheets, paste, drag-drop, between apps.

In the wild

  • Things 3 uses MenuBarExtra for the quick-entry popup that’s a key product feature.
  • 1Password 8 uses SwiftUI for the menu bar extra; macOS app is SwiftUI with AppKit interop for the secure text fields.
  • Linear’s Mac app uses SwiftUI multiplatform; the Mac version adds Settings, Commands, MenuBarExtra.
  • Craft uses extensive NSViewRepresentable for their rich text editor (NSTextView).
  • Bear’s new Mac version uses SwiftUI for chrome, AppKit NSTextView for the editor.

Common misconceptions

  1. “Mac SwiftUI is just iOS SwiftUI with extra modifiers.” No — Mac-specific primitives (MenuBarExtra, Window, Settings, UtilityWindow, FocusedValue) and conventions (toolbar customization, menu commands, keyboard shortcuts) are first-class.
  2. NSApplicationDelegate isn’t needed with SwiftUI.” Often not, but for dock menus, custom URL handling beyond onOpenURL, accessibility hooks, services menu items, you’ll add one via @NSApplicationDelegateAdaptor.
  3. MenuBarExtra is only for accessory apps.” It works for any app that wants a quick-access menu bar item. Mainstream apps (1Password, Notion) have one.
  4. “Inspector is iPad-only.” .inspector(isPresented:) (iOS 17+) is Mac-and-iPad. Standard right-side pane convention.
  5. “Toolbar customization is automatic.” Only when you use ToolbarItem(id:) and .toolbar(id:). Without IDs, items are fixed.

Seasoned engineer’s take

Mac users are conservative — they want apps to behave like Mac apps. The investment is real but pays off: dock menu (5 minutes), Settings scene (10 minutes), proper Commands with focusedSceneValue (an hour), MenuBarExtra (an hour), toolbar customization (15 minutes per toolbar). Each addition makes the app feel more native; the cumulative effect is “this app respects me as a Mac user.”

For long-form text editing (notes, docs, articles), TextEditor is not good enough. Plan to wrap NSTextView. SwiftUI’s Text doesn’t support rich text input either. Apple knows; new APIs may come, but in 2026 NSViewRepresentable is still the answer for rich text.

For data-dense UIs (tables with sortable columns, multi-row selection, drag-reorder), Table covers most cases. Drop to NSTableView when you need column-level cell types, advanced selection behaviors, or virtualized columns.

focusedSceneValue is the trick that makes commands feel right. Without it, your menu items are always enabled (or always disabled), and the wrong window’s action might fire. Spend the time to wire it.

TIP: Test your Mac app with the keyboard only (no mouse). If you can’t navigate every screen and trigger every action, Mac users won’t be able to either. This is also the fastest accessibility test.

WARNING: Don’t ship Mac apps without testing on multiple window sizes, including very narrow (320pt wide) and very wide (2000pt+). SwiftUI layouts that work at one size sometimes break at extremes.

Interview corner

Junior-level: “What’s MenuBarExtra for?”

A SwiftUI Scene (macOS 13+) for adding an item to the system menu bar at the top-right of the screen. Two styles: .menu (dropdown of menu items) and .window (opens a custom SwiftUI view as a popover). Combined with LSUIElement = YES in Info.plist, you can build a menu-bar-only app with no Dock icon.

Mid-level: “How do you enable/disable menu items in Mac SwiftUI based on what view is focused?”

Use FocusedValues and focusedSceneValue. Define a custom FocusedValues entry (using @Entry macro) — e.g., noteActions: NoteActions?. In the focused view, publish the value via .focusedSceneValue(\.noteActions, NoteActions(...)). In your Commands, read it with @FocusedValue(\.noteActions). Disable the menu Button via .disabled(actions == nil). As focus changes between views/windows, the published value changes, and SwiftUI re-evaluates menu state.

Senior-level: “Architect a SwiftUI Mac app that needs: main editor window, menu bar quick-access, inspector pane, keyboard-driven workflow, and integrates with rich text via NSTextView.”

Scene hierarchy:

  • WindowGroup — main editor window with NavigationSplitView { sidebar } detail: { editor }
  • Window("Settings", id: "settings") — settings (or Settings scene if standard)
  • MenuBarExtra — quick actions: new note, search, recent

Editor:

  • NoteEditor uses NSViewRepresentable wrapping NSTextView for rich text
  • Inspector pane via .inspector(isPresented:) — toggleable with ⌥⌘I
  • Toolbar with .toolbar(id:) for user customization

Keyboard:

  • All major actions in Commands with keyboardShortcut
  • focusedSceneValue publishes editor actions (bold, italic, list, link) from the focused editor view
  • Commands disable when no editor focused

Menu structure:

  • Standard menus (File: New, Open, Save, Close; Edit: Undo, Cut/Copy/Paste, Find)
  • Custom Note menu (Toggle Favorite, Pin, Move to…)
  • Custom Format menu (Bold, Italic, Heading 1/2/3, List)
  • View menu with sidebar/inspector toggles (SidebarCommands())

NSTextView bridging:

  • Coordinator handles NSTextViewDelegate callbacks
  • Two-way binding for text content with feedback-loop guard
  • Attribute manipulation (bold/italic) via coordinator methods, exposed as actions in FocusedValues
  • Find panel integration via NSTextFinder

App lifecycle:

  • @NSApplicationDelegateAdaptor for dock menu, services menu items, URL handling

State:

  • @Observable NoteStore injected via .environment to all scenes
  • Per-window state via @SceneStorage
  • Preferences via @AppStorage

Red flag in candidates: Reaching for NSWindow and AppKit-first design when SwiftUI scenes would do. Or, conversely, refusing to drop to NSViewRepresentable for tasks that genuinely require AppKit (rich text editing).

Lab preview

Lab 5.3 (Multiplatform Notes) optionally includes Mac-specific touches: Settings scene, Commands menu, toolbar customization. The lab is a controlled environment to practice the conventions in this chapter.


Next: Environment, PreferenceKey & GeometryReader