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.
| Affordance | API |
|---|---|
| Menu bar status item | MenuBarExtra scene |
| Dock menu | App.commands { ... } or NSApp.dockMenu |
| Floating/auxiliary window | UtilityWindow, custom window controller |
| Inspector pane | .inspector(isPresented:) modifier (iOS 17+/macOS 14+) |
| Keyboard shortcuts | .keyboardShortcut(_:modifiers:) |
| Right-click menu | .contextMenu { ... } |
| Toolbar with customization | Toolbar + ToolbarItem(customizationID:) |
| Native NSView | NSViewRepresentable |
| URL handling | .handlesExternalEvents(...), .onOpenURL |
Concept → Why → How → Code
MenuBarExtra — status item
@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)
}
}
MenuBarExtrais its ownScene.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 customizableplacement: .primaryActionputs in the window toolbar (Mac)Label("Name", systemImage: "icon")— Mac users can show both, icon-only, or text-only via toolbar customizationToolbarItemGroupfor 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 (callingopenWindow(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:
| Shortcut | Action |
|---|---|
⌘N | New |
⌘O | Open |
⌘S | Save |
⌘W | Close window |
⌘Q | Quit |
⌘, | Settings |
⌘Z | Undo |
⇧⌘Z | Redo |
⌘X/C/V | Cut/Copy/Paste |
⌘F | Find |
⌘P | |
⌘+/⌘- | Zoom |
⌥⌘S | Save as / duplicate |
⌥⌘1/2/3 | View modes |
⌥⌘I | Show 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.
MenuBarExtra + LSUIElement for accessory apps
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)
}
}
openURL and deep links
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
MenuBarExtrafor 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
NSViewRepresentablefor their rich text editor (NSTextView). - Bear’s new Mac version uses SwiftUI for chrome, AppKit
NSTextViewfor the editor.
Common misconceptions
- “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. - “
NSApplicationDelegateisn’t needed with SwiftUI.” Often not, but for dock menus, custom URL handling beyondonOpenURL, accessibility hooks, services menu items, you’ll add one via@NSApplicationDelegateAdaptor. - “
MenuBarExtrais only for accessory apps.” It works for any app that wants a quick-access menu bar item. Mainstream apps (1Password, Notion) have one. - “Inspector is iPad-only.”
.inspector(isPresented:)(iOS 17+) is Mac-and-iPad. Standard right-side pane convention. - “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 withNavigationSplitView { sidebar } detail: { editor }Window("Settings", id: "settings")— settings (orSettingsscene if standard)MenuBarExtra— quick actions: new note, search, recent
Editor:
NoteEditorusesNSViewRepresentablewrappingNSTextViewfor rich text- Inspector pane via
.inspector(isPresented:)— toggleable with ⌥⌘I - Toolbar with
.toolbar(id:)for user customization
Keyboard:
- All major actions in
CommandswithkeyboardShortcut focusedSceneValuepublishes 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
NSTextViewDelegatecallbacks - 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:
@NSApplicationDelegateAdaptorfor dock menu, services menu items, URL handling
State:
@Observable NoteStoreinjected via.environmentto 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.