-
Notifications
You must be signed in to change notification settings - Fork 0
Book 03 Introducing Scenes And Windows
Part I — Introduction · Claude's Xcode 26 Swift Bible
← Book-02-Introducing-SwiftUI-Views · Chapters and Appendices · Book-04-Gestures-And-Input →
Claude's Xcode 26 Swift Bible -- Part I: Introduction
All three apps demonstrate the
Appprotocol and scene wiring this chapter teaches, in three different shapes: Inkwell has a singleWindowGrouphostingLockScreenView(its app entry), thenContentViewafter auth — seeClaudes_X26_Swift6_BibleApp.swift. Claudes LockBox does the same with aWindowGrouphosting a Face-ID gatedLockScreenViewthat wraps the actual content — seeClaudes_LockBoxApp.swiftat github.com/fluhartyml/Claudes-LockBox. CryoTunes Player usesWindowGroupplus aSettingsscene on Mac for the user's preferences pane — seeCryoTunes_PlayerApp.swiftat github.com/fluhartyml/CryoTunesPlayer. Three apps, three takes on the same App-protocol pattern. See Build-Along 00 (Inkwell), Build-Along 03 (LockBox), Source Tour 18 (CryoTunes).
Every SwiftUI application starts with a struct that conforms to the App protocol and is marked @main. This is the entry point -- the thing that launches when the user taps your icon.
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}Notice the pattern: App has a body that returns some Scene, just like View has a body that returns some View. The hierarchy goes: App > Scene > View.
- There is exactly one
@mainstruct per target. Two@mainstructs in the same target is a compile error. - The
@mainstruct must conform toApp. - You initialize app-wide state here (SwiftData containers, shared observable objects, etc.).
- If Xcode says "no entry point found," make sure your
Appstruct has the@mainattribute and your file is included in the correct target. - If you rename your app struct, search for leftover references. The
@mainattribute is what matters, not the struct name.
A Scene is a container that manages one or more windows. On iOS, you usually have one scene showing one full-screen window. On macOS, scenes can create multiple windows.
Think of it this way:
- App -- the process, the thing running in memory
- Scene -- a window manager (decides how many windows and what goes in them)
- View -- the actual pixels on screen
SwiftUI provides several built-in scene types:
| Scene Type | What It Does | Platforms | |-----------|-------------|-----------| | WindowGroup | Main app content, supports multiple windows | All | | DocumentGroup | Document-based apps (open/save files) | iOS, macOS | | Settings | Preferences window (Cmd+, on Mac) | macOS only | | Window | A single non-duplicable window | macOS only | | MenuBarExtra | Menu bar item | macOS only |
WindowGroup is the scene type you use 90% of the time. It creates the main window of your app.
@main
struct TallyMatrixApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}On macOS, WindowGroup automatically supports multiple windows. The user can press Cmd+N to open a new window, and each window gets its own independent instance of your content view.
On iOS and iPadOS, WindowGroup creates the single main window. On iPadOS, the system may create multiple scenes for Split View / Slide Over, each getting its own instance.
WindowGroup("Tally Matrix", id: "main") {
ContentView()
}The string sets the window title on macOS. The id lets you distinguish between different window groups if you have more than one.
If your app has multiple WindowGroup scenes, you can open a specific one programmatically:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup("Main", id: "main") {
ContentView()
}
WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
DetailView(itemID: itemID)
}
}
}
// From inside a view:
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Open Detail") {
openWindow(id: "detail", value: selectedItem.id)
}
.font(.system(size: 18))
}
}- On macOS, each window from a
WindowGroupis an independent instance. If they share data, use an@Observableobject passed through.environment(). - On tvOS, there is always exactly one full-screen window.
WindowGroupstill works; the system just never creates a second window.
DocumentGroup is for document-based apps -- apps where the user creates, opens, and saves files (like a text editor, image editor, or spreadsheet).
@main
struct MarkdownEditorApp: App {
var body: some Scene {
DocumentGroup(newDocument: MarkdownDocument()) { file in
EditorView(document: file.$document)
}
}
}Your document type must conform to FileDocument (for value types) or ReferenceFileDocument (for reference types).
struct MarkdownDocument: FileDocument {
static var readableContentTypes: [UTType] { [.plainText] }
var text: String
init(text: String = "") {
self.text = text
}
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents,
let string = String(data: data, encoding: .utf8)
else {
throw CocoaError(.fileReadCorruptFile)
}
self.text = string
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = Data(text.utf8)
return .init(regularFileWithContents: data)
}
}-
DocumentGroupprovides its own navigation (open/save panels on macOS, file browser on iOS). You do not build this UI yourself. - You must register your document's UTType in Info.plist under
CFBundleDocumentTypesandUTExportedTypeDeclarations(for custom file types) orUTImportedTypeDeclarations(for existing types).
The Settings scene creates the standard Preferences window accessible via Cmd+, (or your app's menu > Settings).
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
Settings {
SettingsView()
}
}
}
struct SettingsView: View {
@AppStorage("refreshInterval") private var refreshInterval = 30
@AppStorage("showNotifications") private var showNotifications = true
var body: some View {
Form {
Picker("Refresh Interval", selection: $refreshInterval) {
Text("15 seconds").tag(15)
Text("30 seconds").tag(30)
Text("60 seconds").tag(60)
}
.font(.system(size: 18))
Toggle("Show Notifications", isOn: $showNotifications)
.font(.system(size: 18))
}
.formStyle(.grouped)
.frame(width: 400)
.padding()
}
}-
Settingsis macOS-only. On iOS, build your settings into the app's UI (a settings tab or gear icon) or use the Settings bundle for system Settings.app integration. - Do not use
Settingsfor critical app configuration. Users expect settings to be optional -- the app should work without ever opening this window.
Window creates a single, non-duplicable window. Unlike WindowGroup, the user cannot open a second instance with Cmd+N.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
Window("Activity Log", id: "activity-log") {
ActivityLogView()
}
.keyboardShortcut("L", modifiers: [.command, .shift])
.defaultSize(width: 600, height: 400)
}
}Open it from code with openWindow(id: "activity-log").
MenuBarExtra adds an icon to the macOS menu bar. It can show a dropdown menu or a popover-style window.
@main
struct StatusBarApp: App {
var body: some Scene {
MenuBarExtra("My App", systemImage: "star.fill") {
VStack {
Text("Status: Running")
.font(.system(size: 18))
Button("Quit") {
NSApplication.shared.terminate(nil)
}
}
.padding()
}
.menuBarExtraStyle(.window) // .window for a popover, .menu for a dropdown
}
}- A
MenuBarExtraapp with noWindowGroupis a "menu bar only" app -- it has no dock icon and no main window. This is intentional for utilities. - To have both a dock icon and a menu bar presence, include both
WindowGroupandMenuBarExtrain your app body.
SwiftUI apps use the App protocol's lifecycle. There is no AppDelegate by default, though you can add one if needed.
SwiftUI provides scenePhase to track whether your app is active, inactive, or in the background:
@main
struct MyApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
// App is in the foreground and interactive
break
case .inactive:
// App is visible but not interactive (e.g., switching apps)
break
case .background:
// App is not visible -- save data here
break
@unknown default:
break
}
}
}
}| Phase | Meaning | |-------|---------| | .active | App is frontmost and receiving input | | .inactive | App is visible but not receiving input (transitioning, notification center open) | | .background | App is not visible (user switched away or locked the screen) |
-
Save data in
.background-- this is your last chance before the system may terminate your app. -
Pause timers or animations in
.inactive. -
Resume activity in
.active.
Some things still require an AppDelegate (push notification registration, certain third-party SDKs, handling URLs):
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Setup code here
return true
}
}
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}On macOS, use @NSApplicationDelegateAdaptor instead.
- Do not rely on
scenePhasefor saving critical data on tvOS. tvOS apps can be terminated without warning. Save continuously or after each user action. -
scenePhaseon macOS applies per-scene (per-window), not per-app. Closing one window does not mean the app is backgrounded if another window is open.
SwiftUI runs on every Apple platform from a shared codebase, but each platform has its own conventions to account for.
- One WindowGroup, one full-screen window (iPadOS supports multitasking with multiple scenes).
- No Settings scene -- build settings into your app's UI.
- Navigation typically uses
NavigationStackorNavigationSplitView. - System bars: status bar at top, home indicator at bottom. Use
.ignoresSafeArea()carefully.
-
Multiple windows are the norm. Each
WindowGroupwindow is independent. - Settings scene via Cmd+,.
-
Menu bar is important. SwiftUI generates a default menu; customize with
.commands(). -
Window sizing: use
.defaultSize(),.frame(minWidth:maxWidth:)on the content view.
WindowGroup {
ContentView()
.frame(minWidth: 600, minHeight: 400)
}
.defaultSize(width: 800, height: 600)- Full-screen only. One window, no resizing, no multitasking.
- Focus-based navigation. No touch -- users navigate with the Siri Remote. See Chapter 4 for focus system details.
- 10-foot UI. Everything must be large and readable from across the room. Your 18pt minimum is a floor, not a target -- consider 28pt+ for main content on TV.
- No
Settingsscene, noMenuBarExtra.
-
Windows float in space.
WindowGroupcreates a 2D window in the user's environment. -
Volumes: use
.windowStyle(.volumetric)for 3D content in a bounded box. -
Immersive spaces:
ImmersiveSpacescene type for full 3D environments. - Eyes and hands replace touch. Standard SwiftUI controls work automatically with gaze-and-tap.
@main
struct MyVisionApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.defaultSize(width: 600, height: 400, depth: 0, in: .points)
ImmersiveSpace(id: "immersive") {
ImmersiveView()
}
}
}- Single view, small screen. Focus on glanceable information.
-
NavigationStackwith simple lists is the standard pattern. - Digital Crown input via
.digitalCrownRotation().
When you need platform-specific code:
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}Available compile-time checks:
| Check | Matches | |-------|---------| | #if os(iOS) | iPhone and iPad | | #if os(macOS) | Mac | | #if os(tvOS) | Apple TV | | #if os(watchOS) | Apple Watch | | #if os(visionOS) | Apple Vision Pro | | #if targetEnvironment(simulator) | Running in simulator | | #if canImport(UIKit) | UIKit is available (iOS, tvOS, visionOS) | | #if canImport(AppKit) | AppKit is available (macOS) |
-
os(iOS)matches both iPhone and iPad. UseUIDevice.current.userInterfaceIdiom == .padat runtime to distinguish (but prefer adaptive layout over device checks). - Mac Catalyst apps match
os(iOS), notos(macOS). Use#if targetEnvironment(macCatalyst)to detect Catalyst specifically. - Overusing
#if os(...)makes code hard to maintain. Prefer designing views that adapt naturally withGeometryReader,ViewThatFits, orNavigationSplitView's automatic collapsing behavior.
A real-world macOS app might combine several scene types:
@main
struct ProductivityApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup("Documents", id: "documents") {
DocumentBrowser()
.environment(appState)
}
.commands {
SidebarCommands()
ToolbarCommands()
}
Window("Quick Note", id: "quick-note") {
QuickNoteView()
.environment(appState)
}
.keyboardShortcut("N", modifiers: [.command, .shift])
.defaultSize(width: 400, height: 300)
Settings {
SettingsView()
.environment(appState)
}
MenuBarExtra("Quick Access", systemImage: "note.text") {
MenuBarView()
.environment(appState)
}
.menuBarExtraStyle(.window)
}
}Pass shared @Observable state through .environment() so all scenes can access the same data.
-
Start with one WindowGroup. You can add Settings, MenuBarExtra, and extra windows later. Do not over-architect the scene structure before you need it.
-
Test your macOS app with multiple windows early. Open two windows and make sure they do not fight over shared state. This catches bugs that are invisible in single-window testing.
-
Save in
.background, not just on button tap. Users force-quit apps, phones run out of battery, systems terminate background apps. The.backgroundscene phase is your safety net. -
Use
.defaultSize()on macOS windows. Without it, SwiftUI guesses a size based on content, which is often wrong. -
On tvOS, do not fight the focus system. SwiftUI handles focus automatically for standard controls. Custom focus behavior is covered in Chapter 4.
-
Keep your App struct thin. Initialize state, define scenes, and stop. Business logic belongs in your model layer, not in the
Appbody.
Claude's Xcode 26 Swift Bible -- Book 3 By Michael Fluharty. Swift 6, Xcode 26.
← Book-02-Introducing-SwiftUI-Views · Chapters and Appendices · Book-04-Gestures-And-Input →
Feedback: Found something off? Open an issue · Discuss it · Email Michael
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo