5.10 — Universal & multiplatform apps
Opening scenario
Your iOS app is doing well. The product team wants a Mac version. Options on the table:
- Mac Catalyst — flip a checkbox, ship iPad-on-Mac. Fast, but the result feels foreign on macOS.
- Separate AppKit Mac target — full native fidelity, but a separate codebase to maintain.
- SwiftUI multiplatform — single target, single codebase, runs on iPhone, iPad, and Mac with platform-appropriate adaptations.
In 2026, option 3 is the default for new apps and the right answer for most existing iOS-only apps adding Mac support. SwiftUI’s platform abstractions (Scene, WindowGroup, NavigationSplitView, toolbar placements) generate native-feeling UI on each platform from the same view code.
This chapter is how to actually do it — the scene hierarchy, the conditionals, the universal primitives, and when to drop down to per-platform code.
| Approach | Codebase | Mac fidelity | Best for |
|---|---|---|---|
| Mac Catalyst | iOS, with flag | Medium (iPad-like) | Quick port of existing iPad apps |
| Separate AppKit target | Two | Native | Mac-first or Mac-heavy use cases |
| SwiftUI multiplatform | One | Native (with #if adaptations) | New apps, modern iOS apps adding Mac |
| SwiftUI on Catalyst | One | iPad-like | Rare today; SwiftUI multiplatform is better |
Concept → Why → How → Code
Choosing the approach (decision tree)
- Are you starting fresh and want iOS + Mac (+ maybe iPad)? → SwiftUI multiplatform.
- Mac is a primary platform with desktop-class needs (windows, menu commands, sidebar inspectors)? → SwiftUI multiplatform, lean into AppKit interop where needed.
- You have a large, mature iOS codebase and need a quick Mac port? → Mac Catalyst. Set “Optimize for Mac” in target settings.
- You have a pure Mac product (Final Cut Pro-class)? → Native AppKit or SwiftUI with heavy AppKit interop.
- You support iPhone but Mac is “nice to have”? → SwiftUI multiplatform; minimal Mac-specific tuning.
The App and Scene model
In SwiftUI, the entry point is the App protocol — universal across platforms:
@main
struct NotesApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}
Scene is the unit of UI; App.body returns one or more scenes. Multiplatform-specific scenes:
WindowGroup— multi-window on Mac and iPad; single-window on iPhoneWindow(macOS, iPadOS 16+) — single-instance windowSettings(macOS) — adds the standard “Settings…” menu item and paneMenuBarExtra(macOS) — menu bar status item (covered in chapter 5.11)DocumentGroup— for document-based appsUtilityWindow(macOS 13+) — auxiliary window styles
Multi-window on Mac & iPad
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup("Notes", id: "main") {
NotesView()
}
WindowGroup("Note", id: "note", for: Note.ID.self) { $noteID in
NoteWindow(noteID: noteID)
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
}
// Open a note in a new window
struct NotesView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
List(notes) { note in
Button(note.title) {
openWindow(id: "note", value: note.id)
}
}
}
}
WindowGroup(for:)allows per-window value binding — open one window per note@Environment(\.openWindow)action to open by id and value@Environment(\.dismissWindow)to close
On iPhone, “open new window” is silently a no-op or replaces content (iPhone doesn’t have multi-window). On iPad and Mac, you get genuine new windows.
Universal primitives that adapt
SwiftUI’s high-value primitives behave platform-appropriately:
| Primitive | iPhone | iPad | Mac |
|---|---|---|---|
NavigationStack | Push/pop | Push/pop | Push/pop |
NavigationSplitView | Stack (collapsed) | Sidebar+detail | Sidebar+detail (native split) |
List | UITableView-style | UITableView/sidebar | NSTableView-style |
Form | Settings-style grouped | Settings-style | Mac-style with right-aligned labels |
Toolbar | Navigation bar | Navigation bar | Window toolbar |
Sheet | Modal sheet | Sheet or formsheet | Modal sheet (resizable) |
Menu | Pull-down menu | Pull-down menu | Native menu |
ContextMenu | Long-press menu | Right-click/long-press | Right-click menu |
KeyboardShortcut | Hardware kbd | Hardware kbd | Menu equivalent |
You write NavigationSplitView { sidebar } detail: { detail } and SwiftUI adapts: iPhone shows the stack; iPad shows the split; Mac shows the resizable split. Same code.
Platform conditionals — when you need them
Compile-time:
#if os(iOS)
.navigationBarTitleDisplayMode(.large)
#elseif os(macOS)
.frame(minWidth: 400, minHeight: 300)
#endif
#if targetEnvironment(macCatalyst)
.toolbarRole(.editor)
#endif
Runtime (rare, prefer compile-time):
if ProcessInfo.processInfo.isMacCatalystApp {
// ...
}
Common conditional needs:
- Window sizing (Mac wants min frame)
- Toolbar placement (
.bottomBaris iPhone-only) - Hover effects (
.onHovermostly Mac/iPad) - Mac-specific commands menus
- iOS-specific haptics (
.sensoryFeedback) - iPhone-only navigation bar styles
Commands — Mac menu bar
Mac apps live in the menu bar. SwiftUI’s Commands:
@main
struct NotesApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
#if os(macOS)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Note") { store.createNew() }
.keyboardShortcut("n", modifiers: .command)
}
CommandMenu("Note") {
Button("Toggle Favorite") { store.toggleFavorite() }
.keyboardShortcut("f", modifiers: [.command, .shift])
Divider()
Button("Export…") { store.exportSelected() }
.keyboardShortcut("e", modifiers: .command)
}
}
#endif
}
}
CommandGroup(replacing:)overrides system menus (File → New Item, etc.)CommandGroup(after:)/before:add to existing system menusCommandMenu("…")adds a top-level menu- Buttons in commands become menu items;
keyboardShortcutmakes them invokable
On iPad, hardware keyboard users get the same shortcuts via the discoverability hint (Cmd-hold). On iPhone, commands are ignored (no menu bar).
focusedSceneValue — what menus act on
Commands need to know what they’re acting on (which document? which selection?). The pattern:
extension FocusedValues {
@Entry var selectedNoteAction: (() -> Void)?
}
// In a view that has focus:
ContentView()
.focusedSceneValue(\.selectedNoteAction) {
toggleFavorite()
}
// In commands:
.commands {
CommandMenu("Note") {
FocusedValueButton("Toggle Favorite", \.selectedNoteAction)
}
}
focusedSceneValue publishes values from focused views; Commands reads them. The action is enabled only when the focused view publishes it.
Settings scene (macOS)
#if os(macOS)
Settings {
TabView {
GeneralSettings()
.tabItem { Label("General", systemImage: "gear") }
AppearanceSettings()
.tabItem { Label("Appearance", systemImage: "paintbrush") }
}
.frame(width: 400, height: 300)
}
#endif
Settings adds “Settings…” to the app menu (⌘,). Standard Mac convention; users expect it.
Sharing model & business logic
The model layer is fully platform-independent — no UIKit or AppKit imports. View models, services, persistence (SwiftData/Core Data), networking: all shared across platforms.
// Shared
@MainActor @Observable
final class NoteStore {
var notes: [Note] = []
func createNew() { ... }
}
// View layer reuses the store on every platform
ContentView().environment(store) // works on iOS, iPadOS, macOS
If your model layer references UIImage, abstract to a cross-platform image type (or use CGImage / Image(_:from:)).
File organization
Common patterns:
Single-target, conditional includes:
NotesApp/
├── Sources/
│ ├── App.swift
│ ├── Views/
│ │ ├── ContentView.swift
│ │ ├── NoteRow.swift
│ │ └── Mac/
│ │ └── InspectorView.swift // #if os(macOS) at top
│ ├── Models/
│ └── Services/
Per-platform folders, conditional compilation:
NotesApp/
├── Shared/ // shared sources
├── iOS/ // iOS-only sources
└── Mac/ // Mac-only sources
For very platform-different UI (e.g., a Mac sidebar inspector vs iPhone modal), use separate view files with #if os(macOS) at the top.
Sizing & windows
WindowGroup {
ContentView()
}
.windowResizability(.contentSize) // sized to content, user can't resize
.defaultSize(width: 800, height: 600)
.defaultPosition(.center)
.commands {
SidebarCommands() // adds "Toggle Sidebar" menu item
ToolbarCommands() // adds "Customize Toolbar…"
}
SidebarCommands() and ToolbarCommands() add system-standard menu items for free.
Catalyst vs SwiftUI multiplatform
If your codebase is currently iOS and you’re considering paths:
Catalyst:
- Pros: minimal effort, ship Mac version in days
- Cons: feels like iPad-on-Mac (oversized controls, modal sheets), limited Mac integration, weird scrollbar behavior
- Mitigations: “Optimize for Mac” flag (Xcode 13+) helps, but still not native-feeling
SwiftUI multiplatform:
- Pros: native Mac feel, easier to add commands and proper windowing
- Cons: must use SwiftUI on iOS (or extract UIKit into UIViewRepresentables for the Mac path)
- Effort: requires migrating iOS UIKit screens to SwiftUI (or accept the rewrite as part of multiplatform push)
If your iOS app is SwiftUI: multiplatform is straightforward. If your iOS app is UIKit: Catalyst is faster; SwiftUI multiplatform is a larger investment but pays off long-term.
Mac Catalyst tips (if you go that route)
- Enable “Optimize for Mac” in target settings → controls scale natively
- Use
#if targetEnvironment(macCatalyst)for Mac-specific code paths - Hide iPad-only UI elements (page sheets that don’t make sense as Mac modals)
- Add native macOS menus via
UIMenuBuilder(UIKit’s Mac menu API) - Test resize behavior; iPad UIs often break at very wide aspect ratios
@Environment(\.openWindow) and friends
Mac/iPad multi-window actions:
@Environment(\.openWindow) var openWindow
@Environment(\.dismissWindow) var dismissWindow
@Environment(\.openURL) var openURL
Button("Open") {
openWindow(id: "note", value: noteID)
}
On iPhone, these are no-ops or behave as best they can.
In the wild
- Apple’s Reminders, Notes, Mail apps are SwiftUI multiplatform — single codebase, native feel on iPhone, iPad, Mac.
- Things 3 (Cultured Code) was AppKit-only for years; their newer features ship as SwiftUI multiplatform.
- Craft uses SwiftUI multiplatform with heavy AppKit interop on Mac for advanced text editing.
- Bear (note-taking app) is currently Mac Catalyst; the new version is rumored to migrate to SwiftUI multiplatform.
- Apollo for Reddit (RIP) was SwiftUI on iOS; never shipped Mac.
- Apple’s Sample Code “Backyard Birds” is the canonical SwiftUI multiplatform example (iOS + iPadOS + macOS + watchOS + tvOS from one target).
Common misconceptions
- “SwiftUI on Mac is just SwiftUI on iPhone in a window.” No —
Toolbar,Menu,Settings,NavigationSplitView, multi-window, and AppKit interop give SwiftUI access to Mac-specific affordances. Done well, it’s genuinely native. - “Mac Catalyst is dead.” Not at all — for porting iPad-heavy apps, it’s the fastest path. Apple still ships updates to Catalyst.
- “SwiftUI multiplatform means one identical UI on every device.” It means one codebase that adapts. Sidebars on Mac, stacks on iPhone, same view code with
NavigationSplitView. - “You can’t mix SwiftUI and AppKit.” You can —
NSViewRepresentableandNSHostingControllerare the AppKit equivalents of UIKit’s. Chapter 5.11. - “Multi-window is hard.” With
WindowGroup(for:)and@Environment(\.openWindow), it’s a few lines.
Seasoned engineer’s take
For new apps in 2026 with a desktop ambition: SwiftUI multiplatform from day one. The leverage is enormous — every feature ships on every platform automatically.
For existing iOS apps adding Mac: evaluate honestly. If your iOS code is UIKit and you don’t have appetite to migrate, ship Catalyst with “Optimize for Mac” and iterate. If you can migrate to SwiftUI gradually, do that and reap multi-platform benefits.
Don’t reach for cross-platform third-party frameworks (Flutter, React Native) just for Mac support. SwiftUI multiplatform is the native answer with better integration, performance, and long-term support.
The biggest mistake I see: shipping a Catalyst app that looks like iPad-on-Mac and calling it done. Mac users notice immediately — wrong scrollbars, oversized controls, no menu bar, no keyboard shortcuts. Either invest in proper Mac integration (commands, focused values, native window styles) or use SwiftUI multiplatform from the start.
TIP: Test on every platform from day one. Set up CI to build for iOS, iPadOS, and macOS on every PR. Catching “this scene only works on iOS” early saves agony.
WARNING:
frame(...)behaves differently on Mac (window starts at that size unless.windowResizability(.contentSize)) vs iOS (frame within parent). Test both.
Interview corner
Junior-level: “Mac Catalyst vs SwiftUI multiplatform — when do you use each?”
Catalyst when you have an existing iPad-heavy UIKit app and want the fastest path to Mac. SwiftUI multiplatform when you’re starting fresh or your codebase is already SwiftUI — produces a more native-feeling Mac experience because SwiftUI’s primitives (Toolbar, Commands, NavigationSplitView, Settings) adapt to platform conventions rather than forcing iPad UI onto Mac.
Mid-level: “How would you structure a SwiftUI multiplatform notes app supporting iPhone, iPad, and Mac?”
Single target with @main App containing platform-appropriate scenes: WindowGroup for the main UI; WindowGroup(for: Note.ID.self) for per-note detached windows on iPad/Mac; Settings scene on Mac. The main view is NavigationSplitView with a sidebar (folders), content (notes list), detail (editor) — adapts: iPhone collapses to stack, iPad/Mac shows split. Shared @Observable NoteStore injected via .environment. Platform-specific code via #if os(macOS) blocks for: window sizing (.frame(minWidth:minHeight:)), commands menu, hover effects on Mac. iOS-only blocks for haptics. Model and service layers are pure Swift, no platform imports.
Senior-level: “A user opens a note in a new window on Mac, edits it, then quits the app. Expected behavior on relaunch?”
The system should restore the open windows. SwiftUI handles this when:
- The window’s
WindowGroup(for: Note.ID.self)uses aCodablevalue type for the binding — SwiftUI persists the window-value associations - The model layer can hydrate the note by ID (so when the window reconstructs with the saved ID, it can render content)
- App-level state (selected folder, sidebar visibility) is saved via
@SceneStorage
Implementation:
WindowGroup("Note", id: "note", for: Note.ID.self) { $noteID in
if let id = noteID, let note = store.note(for: id) {
NoteEditor(note: note)
}
}
@SceneStorage for per-window UI state (selected text range, scroll position). @AppStorage for global preferences (sidebar default width).
Edge cases:
- Note deleted while window persisted → show “Note no longer exists” placeholder
- Notes opened but store still loading → show loading state, hydrate when ready
- iCloud sync conflict on relaunch → present conflict resolution UI
Red flag in candidates: Saying “just use Catalyst” without considering the tradeoffs, or saying “rewrite everything in SwiftUI” without acknowledging the cost.
Lab preview
Lab 5.3 (Multiplatform Notes) builds a single-target iOS + macOS notes app with NavigationSplitView, shared @Observable store, platform-conditional toolbars, and Settings scene on Mac.
Next: SwiftUI macOS advanced