App Structure and Life Cycle (SwiftUI, UIKit, AppKit)

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every Apple app answers the same three questions at launch: where does execution start, what owns the windows on screen, and who is told when the app moves between the foreground and the background? SwiftUI, UIKit and AppKit answer them in different ways. SwiftUI uses a declarative App with a list of scenes. UIKit uses an app delegate for the process and a scene delegate for each window. AppKit uses an NSApplication object and its delegate. This page walks through all three using the 2026 baseline of this section: iOS, iPadOS, macOS, watchOS and visionOS 27, Xcode 27 and Swift 6.4, with a deployment minimum of 26. It builds FieldNotes' real entry point, FieldNotesApp, and shows the UIKit and AppKit equivalents you meet in older code or need for specific integrations.

The most important change of the year sits in UIKit. With the 27 SDKs, UIKit apps must use the scene-based life cycle. An app built with Xcode 27 that has no scene configuration fails to launch on iOS, iPadOS, Mac Catalyst, tvOS and visionOS 27. SwiftUI apps already use scenes and are unaffected. The UIKit section below shows what to add.

Processes, Apps, Scenes and Windows

It helps to separate four things that older UIKit code tended to mix up:

Concept What it is

Process

The running executable. The system launches it once, may suspend it in the background and may terminate it at any time to reclaim memory. There is exactly one per app.

App object

The one object that represents the process to the framework: the App value in SwiftUI, UIApplication in UIKit, NSApplication in AppKit. Process-wide events arrive here: launch, push-notification registration, memory warnings, termination.

Scene

One instance of the app’s user interface, with its own life cycle. On iPad, Mac and Apple Vision Pro, a person can open several FieldNotes windows at once: each is a scene, and one can be in the foreground while another is in the background.

Window

The on-screen container a scene draws into (UIWindow, NSWindow). In SwiftUI you almost never touch windows directly: the scene creates them.

In SwiftUI and in modern UIKit, life-cycle events are per scene, not per app. "Save when the app goes to the background" really means "save when the last scene goes to the background", or better, "save when this scene goes to the background".

SwiftUI: App, @main and Scenes

A SwiftUI app is a type that conforms to the App protocol and is marked @main, the attribute that tells the compiler where the program starts. There is no main.swift, no UIApplicationMain call and no storyboard. The App protocol supplies a static main() that starts the framework, and its body returns one or more scenes. This is FieldNotes' complete entry point:

// FieldNotes/FieldNotesApp.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
import FieldNotesUI

@main
struct FieldNotesApp: App {
    /// Created once for the life of the process and shared by every scene.
    @State private var store = NoteStore(syncer: CloudNoteSyncer())
    @Environment(\.scenePhase) private var scenePhase

    #if os(iOS) || os(visionOS)
    @UIApplicationDelegateAdaptor private var appDelegate: FieldNotesAppDelegate
    #elseif os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: FieldNotesMacDelegate
    #endif

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .background {
                store.saveIfNeeded()
            }
        }

        #if os(macOS)
        Window("Tag Browser", id: "tag-browser") {
            TagBrowser()
                .environment(store)
        }

        UtilityWindow("Note Info", id: "note-info") {
            NoteInfoPanel()
        }

        Settings {
            SettingsView()
                .environment(store)
        }

        MenuBarExtra("FieldNotes", systemImage: "note.text") {
            QuickCaptureMenu()
                .environment(store)
        }
        .menuBarExtraStyle(.window)
        #endif

        #if os(visionOS)
        ImmersiveSpace(id: "map") {
            NotesMapSpace()
                .environment(store)
        }
        #endif
    }
}

A few things in that listing deserve comment:

  • @State holds the store. NoteStore is an @Observable class (see Properties, § The Observation Module’s @Observable). In Xcode 27, @State is a macro that expands to State(), so a class stored in it is initialized only once, when SwiftUI first creates the App value. With earlier toolchains the initializer expression could run again (and the extra instance was thrown away), which is why older code often created stores in a static let or an init. State and Data Flow with Observation covers the details.

  • The store is injected per scene. .environment(store) is applied inside each scene’s content, because the environment flows down view hierarchies, and each scene has its own. Every FieldNotes view then reads it with @Environment(NoteStore.self). App Architecture the Apple Way explains the pattern.

  • Platform differences are compile-time. #if os(macOS) and #if os(visionOS) inside the scene builder remove scene types that don’t exist on other platforms. The directives are covered in the Swift reference on Attributes and Compiler Control. How the one FieldNotes target builds for five platforms is the topic of One Codebase for iOS, iPadOS, macOS, watchOS and visionOS.

  • saveIfNeeded() is a small NoteStore extension in FieldNotesServices that writes the notes to the shared App Group container, shown below. Later in this section the store is backed by SwiftData (SwiftData), which saves automatically, but an explicit save on .background remains a good habit.

// Packages/FieldNotesKit/Sources/FieldNotesServices/NoteStore+Saving.swift
import Foundation
import FieldNotesModel

extension NoteStore {
    /// Writes notes and notebooks to the App Group container, so the widget and watch app can read them.
    public func saveIfNeeded() {
        guard let folder = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.fieldnotes") else { return }
        let snapshot = NotesSnapshot(notes: notes, notebooks: notebooks)   // Codable wrapper
        try? JSONEncoder().encode(snapshot)
            .write(to: folder.appending(path: "notes.json"), options: .atomic)
    }
}

The built-in scene types

Scene Platforms Purpose

WindowGroup

All

The main scene. On iPadOS, macOS and visionOS a person can open several windows from it, each with independent state. On iPhone and Apple Watch there is one.

Window

macOS, visionOS (26+)

A single, unique window, such as FieldNotes' Tag Browser. Opening it again brings the existing window forward.

UtilityWindow

macOS

A floating panel that shows information about whichever main window is focused, such as an inspector. It hides when the app is inactive.

DocumentGroup

iOS, iPadOS, macOS, visionOS

A document-based app: the system provides the document browser, Open and Save panels, and one window per document.

Settings

macOS

The app’s Settings… window, opened with Cmd+, from the app menu.

MenuBarExtra

macOS

A persistent item in the system menu bar, available even when the app has no windows open.

ImmersiveSpace

visionOS

Unbounded 3D content around the person, beyond the bounds of a window.

Windows with an id are opened from any view with the openWindow environment action. FieldNotes' toolbar on the Mac opens the tag browser this way:

// FieldNotes/Mac/TagBrowserToolbarButton.swift
import SwiftUI

struct TagBrowserToolbarButton: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Tags", systemImage: "tag") {
            openWindow(id: "tag-browser")
        }
    }
}

UtilityWindow content usually reads a focused value published by the frontmost main window. NoteDetail publishes the note it shows, and the Note Info panel follows it as the person switches windows:

// Packages/FieldNotesUI/Sources/FieldNotesUI/FocusedNote.swift
import SwiftUI
import FieldNotesModel

extension FocusedValues {
    @Entry public var selectedNote: Note?
}

// In NoteDetail's body:  .focusedSceneValue(\.selectedNote, note)

// FieldNotes/Mac/NoteInfoPanel.swift
struct NoteInfoPanel: View {
    @FocusedValue(\.selectedNote) private var note

    var body: some View {
        if let note {
            Form {
                LabeledContent("Created", value: note.createdAt, format: .dateTime)
                LabeledContent("Tags", value: note.tags.joined(separator: ", "))
                LabeledContent("Words", value: note.body.split(separator: " ").count, format: .number)
            }
        } else {
            ContentUnavailableView("No Note Selected", systemImage: "note.text")
        }
    }
}

MenuBarExtra with .menuBarExtraStyle(.window) shows a small SwiftUI panel instead of a menu, which suits FieldNotes' quick-capture field. An app that consists only of a MenuBarExtra is terminated automatically when the person removes the item from the menu bar.

On visionOS, the ImmersiveSpace with the id "map" places FieldNotes' geotagged notes around the person. At most one immersive space is open at a time, and opening it is asynchronous, because the system may decline:

// FieldNotes/Vision/OpenMapSpaceButton.swift
import SwiftUI

struct OpenMapSpaceButton: View {
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace

    var body: some View {
        Button("Show Notes Around Me", systemImage: "map") {
            Task {
                _ = await openImmersiveSpace(id: "map")
            }
        }
    }
}

visionOS: Windows, Volumes, Immersive Spaces and RealityKit covers immersion styles and RealityKit content.

DocumentGroup and the 27 document model

FieldNotes is a library-style app: one store holds every note. Apps that edit files, such as a text editor, use DocumentGroup instead of WindowGroup. In the 27 SDKs, the document protocols changed: FileDocument and ReferenceFileDocument are listed as deprecated, replaced by the class-based ReadableDocument and WritableDocument protocols, and DocumentGroup gains matching initializers, new in iOS, iPadOS, macOS and visionOS 27. If FieldNotes added a viewer for exported .fieldnotesarchive files, the scene would look like this:

// FieldNotes/Archive/ArchiveViewerScene.swift
import SwiftUI

@available(iOS 27, macOS 27, visionOS 27, *)
struct ArchiveViewerScene: Scene {
    var body: some Scene {
        DocumentGroup(
            viewer: { (archive: NotesArchive) in
                ArchiveViewer(archive: archive)
            },
            makeReadableDocument: { configuration, _ in
                try await NotesArchive(configuration: configuration)
            }
        )
    }
}

NotesArchive is a class conforming to ReadableDocument. Implementing the protocol, with its readers, writers and snapshots, belongs to Preferences, Files and Documents.

Scene Phases

Every scene is in one of three ScenePhase values, which SwiftUI publishes in the environment:

  • .active: in the foreground and interactive.

  • .inactive: visible but not receiving events, for example while Control Center or the app switcher is shown, or while a Mac window is behind another app’s window. Pause timers and animations here, but don’t assume the scene will leave the screen.

  • .background: not visible. The process may be suspended soon after, so save state and stop work here.

Two further states exist that SwiftUI never reports, because the app’s code doesn’t run in them: suspended, where the process stays in memory but gets no CPU time, and not running, before launch or after the system terminated the process. A suspended app receives no notification before it is terminated. That is why the .background transition is the last reliable moment to save.

Figure: Scene phases and process states

stateDiagram-v2 [*] --> NotRunning NotRunning --> Inactive: launch in foreground NotRunning --> Background: launch in background (for example a background task) Inactive --> Active: scene becomes interactive Active --> Inactive: interruption, app switcher, window loses focus Inactive --> Background: scene leaves the screen Background --> Inactive: person returns to the scene Background --> Suspended: work finished, system freezes the process Suspended --> Background: system wakes the app for an event Suspended --> Terminated: system reclaims memory Background --> Terminated: system reclaims memory Terminated --> [*] note right of Active ScenePhase reports only active, inactive and background. Suspended and terminated are process states. end note

Where you read scenePhase changes its meaning. Read from a view, it is the phase of the scene that contains the view. Read from the App, as FieldNotesApp does above, it aggregates every scene: the app is .active if any scene is active, and .background only once all scenes are in the background. FieldNotes uses both: the app-level handler saves the store, and the note editor saves its draft when its own window goes inactive:

// FieldNotes/Views/NoteEditor.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct NoteEditor: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.scenePhase) private var scenePhase
    let noteID: Note.ID

    var body: some View {
        @Bindable var store = store
        if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
            Form {
                TextField("Title", text: $store.notes[index].title)
                TextEditor(text: $store.notes[index].body)
            }
            .onChange(of: scenePhase) { _, newPhase in
                if newPhase != .active {
                    store.saveIfNeeded()   // this window lost focus: flush its edits now
                }
            }
        } else {
            ContentUnavailableView("No Note Selected", systemImage: "note.text")
        }
    }
}

The editor binds straight into the store, as described on App Architecture the Apple Way, so there is no separate draft to commit: the phase handler only makes sure edits reach disk. Work that must continue in the background, such as syncing, doesn’t belong in a phase handler. It needs a background task, described on Background Execution.

Delegate Adaptors

Some system events still arrive only through a UIKit, AppKit or WatchKit delegate method, with no SwiftUI equivalent: the push-notification device token, Home Screen quick actions, the Dock menu, watchOS background refresh tasks. A delegate adaptor property wrapper in the App tells SwiftUI to create your delegate object and forward those callbacks to it. Declare each adaptor once, and only in the App type. Declaring it twice is a runtime error. Prefer SwiftUI’s own APIs, such as ScenePhase or onOpenURL, whenever one exists, and keep delegates for what remains.

UIApplicationDelegateAdaptor (iOS, iPadOS, visionOS)

FieldNotes registers for remote notifications at launch and handles a New Note Home Screen quick action. The quick action is a scene event, so the app delegate hands SwiftUI a scene delegate class through application(_:configurationForConnecting:options:):

// FieldNotes/iOS/FieldNotesAppDelegate.swift
import UIKit

final class FieldNotesAppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        application.registerForRemoteNotifications()
        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        PushRegistration.shared.record(deviceToken)
    }

    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        if connectingSceneSession.role == .windowApplication {
            configuration.delegateClass = FieldNotesSceneDelegate.self
        }
        return configuration
    }
}

final class FieldNotesSceneDelegate: NSObject, UIWindowSceneDelegate {
    func windowScene(
        _ windowScene: UIWindowScene,
        performActionFor shortcutItem: UIApplicationShortcutItem
    ) async -> Bool {
        guard shortcutItem.type == "com.example.fieldnotes.new-note" else { return false }
        QuickActions.shared.pending = .newNote
        return true
    }
}

In a SwiftUI app, the scene configuration only names the delegate class: no storyboard and no scene class, because SwiftUI creates the window itself. If the delegate also conforms to ObservableObject, SwiftUI puts it in the environment, where views can read it with @EnvironmentObject. Push notifications are covered on Notifications and Live Activities.

NSApplicationDelegateAdaptor (macOS)

On the Mac, FieldNotes keeps running after its last window closes, because the MenuBarExtra stays useful, and it adds a New Note item to its Dock menu:

// FieldNotes/Mac/FieldNotesMacDelegate.swift
import AppKit

final class FieldNotesMacDelegate: NSObject, NSApplicationDelegate {
    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        false
    }

    func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
        let menu = NSMenu()
        menu.addItem(withTitle: String(localized: "New Note"),
                     action: #selector(newNote(_:)), keyEquivalent: "")
            .target = self
        return menu
    }

    @objc private func newNote(_ sender: Any?) {
        QuickActions.shared.pending = .newNote
    }
}

WKApplicationDelegateAdaptor (watchOS)

The FieldNotesWatch target has its own App. Its delegate handles background refresh tasks, which the system schedules to keep the watch’s copy of recent notes current:

// FieldNotesWatch/FieldNotesWatchApp.swift
import SwiftUI
import WatchKit
import FieldNotesServices

@main
struct FieldNotesWatchApp: App {
    @WKApplicationDelegateAdaptor private var delegate: WatchAppDelegate
    @State private var store = NoteStore(syncer: CloudNoteSyncer())

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

final class WatchAppDelegate: NSObject, WKApplicationDelegate {
    func applicationDidFinishLaunching() {
        RefreshScheduler.scheduleNext()
    }

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        for task in backgroundTasks {
            RefreshScheduler.scheduleNext()
            task.setTaskCompletedWithSnapshot(false)
        }
    }
}

RefreshScheduler and PushRegistration are small FieldNotes helpers. Their details are in watchOS: App Structure, Connectivity, Workouts and the Smart Stack and Background Execution.

UIKit: the App Delegate and Scene Delegates

A UIKit app splits the life cycle between two kinds of objects:

  • UIApplicationDelegate, one per process: launch (application(_:didFinishLaunchingWithOptions:)), push registration, memory warnings, termination, and choosing a configuration for each new scene.

  • UIWindowSceneDelegate, one per scene: creating the scene’s UIWindow and root view controller, and the scene’s own foreground and background transitions.

Before FieldNotes moved to SwiftUI’s life cycle, its 1.x releases were a UIKit app hosting SwiftUI screens. That version is a good reference for UIKit’s structure. The app delegate is the entry point and chooses the scene configuration:

// FieldNotes 1.x/AppDelegate.swift
import UIKit

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    let store = NoteStore()

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Process-wide setup only. No windows here: scenes create them.
        return true
    }

    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

The scene delegate builds the window and responds to that scene’s transitions:

// FieldNotes 1.x/SceneDelegate.swift
import UIKit
import SwiftUI

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene,
              let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }

        let root = ContentView().environment(appDelegate.store)
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = UIHostingController(rootView: root)
        window?.makeKeyAndVisible()
    }

    func sceneWillResignActive(_ scene: UIScene) { /* pause timers */ }
    func sceneDidEnterBackground(_ scene: UIScene) {
        (UIApplication.shared.delegate as? AppDelegate)?.store.saveIfNeeded()
    }
    func sceneDidDisconnect(_ scene: UIScene) { /* release this scene's resources */ }
}

The system needs to know which scene configurations exist. You declare them in Info.plist under the UIApplicationSceneManifest key, or in the target’s Info tab under Application Scene Manifest. $(PRODUCT_MODULE_NAME) expands to the Swift module name, which prefixes the delegate’s class name:

<!-- FieldNotes 1.x/Info.plist -->
<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>

UIApplicationSupportsMultipleScenes set to true lets people open several FieldNotes windows on iPad. Each configuration needs a unique UISceneConfigurationName. When the configuration depends on runtime information, such as a NSUserActivity that asks for an inspector window, return a different name from application(_:configurationForConnecting:options:), as the app delegate above does.

Figure: UIKit launch sequence

sequenceDiagram participant OS as iOS participant Proc as FieldNotes process participant AD as AppDelegate participant UIK as UIKit (UIApplication) participant SD as SceneDelegate participant Win as UIWindow OS->>Proc: Launch process, run @main Proc->>UIK: UIApplicationMain creates UIApplication UIK->>AD: application(_:didFinishLaunchingWithOptions:) AD-->>UIK: true (process-wide setup done) OS->>UIK: Request a scene session (person opens the app) UIK->>AD: application(_:configurationForConnecting:options:) AD-->>UIK: UISceneConfiguration "Default Configuration" UIK->>SD: Instantiate delegate class from the scene manifest UIK->>SD: scene(_:willConnectTo:options:) SD->>Win: UIWindow(windowScene:), set rootViewController SD->>Win: makeKeyAndVisible() UIK->>SD: sceneWillEnterForeground(_:) UIK->>SD: sceneDidBecomeActive(_:)

Mandatory with the 27 SDK

The scene-based life cycle has existed since iOS 13, but UIKit kept supporting apps without it. That ends in 2026:

  • From iOS 18.4 onwards, UIKit logged "This process does not adopt UIScene lifecycle" for apps without scenes.

  • In the 26 releases, the message became "UIScene lifecycle will soon be required".

  • Beginning in iOS, iPadOS, Mac Catalyst, tvOS and visionOS 27, an app built with the latest SDK must adopt the scene-based life cycle, or it fails to launch.

An app needs to migrate if its Info.plist has no UIApplicationSceneManifest (or the manifest has no configurations) and its app delegate doesn’t implement application(_:configurationForConnecting:options:). The migration consists of adding a scene manifest (or the configuration method), creating a UIWindowSceneDelegate that builds the window, and moving the old app-level transition handlers to the scene delegate:

Old UIApplicationDelegate method Scene delegate replacement

applicationDidBecomeActive(_:)

sceneDidBecomeActive(_:)

applicationWillResignActive(_:)

sceneWillResignActive(_:)

applicationDidEnterBackground(_:)

sceneDidEnterBackground(_:)

applicationWillEnterForeground(_:)

sceneWillEnterForeground(_:)

Once an app adopts scenes, UIKit stops calling the four application methods, even if they are still implemented. Code that needs the aggregate state across all scenes, such as "is any FieldNotes window in the foreground?", should observe the UIApplication notifications instead:

// FieldNotes 1.x/SyncCoordinator.swift
import UIKit

@MainActor
final class SyncCoordinator {
    private var observation: (any NSObjectProtocol)?

    func start(store: NoteStore) {
        observation = NotificationCenter.default.addObserver(
            forName: UIApplication.willEnterForegroundNotification,
            object: nil, queue: .main
        ) { _ in
            MainActor.assumeIsolated { Task { try? await store.refresh() } }
        }
    }
}

After migrating, test the app on iPad in full-screen, windowed and Stage Manager modes, where scenes really are independent. The WWDC26 session Modernize your UIKit app walks through this migration together with the other UIKit changes of the year, and UIKit and AppKit Essentials, and Mixing Them with SwiftUI covers hosting SwiftUI in UIKit in depth.

The 27 SDK also changes external displays. Until iOS 26 the system automatically connected a non-interactive external-display scene. Beginning in iOS 27, an app receives that scene only after it registers a scene accessory (UISceneAccessory, new in iOS, iPadOS and Mac Catalyst 27) from a view controller. FieldNotes 1.x would use it to show the current note full-screen on a connected display:

// FieldNotes 1.x/NoteDetailViewController.swift
import UIKit

final class NoteDetailViewController: UIViewController {
    // Typed as Any? because the deployment minimum (26) predates UISceneAccessoryRegistration.
    private var displayRegistration: Any?

    override func viewDidLoad() {
        super.viewDidLoad()
        if #available(iOS 27, *) {
            let configuration = UISceneConfiguration()
            configuration.delegateClass = ExternalNoteSceneDelegate.self
            let accessory = UISceneAccessory.externalNonInteractive(sceneConfiguration: configuration)
            displayRegistration = registerSceneAccessory(accessory)
        }
    }
}

Because FieldNotes' deployment minimum is 26, the registration call stays behind the if #available(iOS 27, *) check, and the registration is stored as Any? so that no stored property names a 27-only type.

AppKit: NSApplication and Its Delegate

A native AppKit app has exactly one NSApplication instance, reachable as NSApplication.shared or the global NSApp. It runs the main event loop, receives events from the window server and dispatches them to the right NSWindow, keeps track of windows and menus, and posts app-level notifications. You customize it through an NSApplicationDelegate, not by subclassing. Unlike UIKit, AppKit has no scene objects: each NSWindow has its own life cycle, and "background" on the Mac means "another app is active", while the app keeps running.

An AppKit app that builds its interface in code, with no main storyboard, starts from main.swift:

// FieldNotesMacLegacy/main.swift
import AppKit

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()

The delegate creates the first window at launch, supports secure state restoration and decides what happens when the last window closes:

// FieldNotesMacLegacy/AppDelegate.swift
import AppKit
import SwiftUI

final class AppDelegate: NSObject, NSApplicationDelegate {
    private let store = NoteStore()
    private var window: NSWindow?

    func applicationDidFinishLaunching(_ notification: Notification) {
        let window = NSWindow(contentViewController:
            NSHostingController(rootView: ContentView().environment(store)))
        window.title = "FieldNotes"
        window.setFrameAutosaveName("MainWindow")
        window.makeKeyAndOrderFront(nil)
        self.window = window
    }

    func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
        true
    }

    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
        store.saveIfNeeded()
        return .terminateNow
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        true
    }
}

A project created from Xcode’s AppKit template instead marks the delegate class @main and loads a main storyboard named in Info.plist. macOS 27, Golden Gate, runs only on Apple silicon and is the last release to include Rosetta, so an old Intel-only AppKit app is a candidate for this migration now. macOS: Windows, Menus, Commands, Settings and Menu Bar Extras covers menus, commands and window management in SwiftUI.

Migrating to the SwiftUI Life Cycle

Moving a UIKit (or AppKit) app to the SwiftUI life cycle doesn’t require rewriting its screens. FieldNotes moved from the 1.x structure above to the current FieldNotesApp in four steps:

  1. Create the App. Add FieldNotesApp.swift with @main struct FieldNotesApp: App and a WindowGroup, and remove @main (or @UIApplicationMain) from the app delegate.

  2. Keep the delegate through an adaptor. Add @UIApplicationDelegateAdaptor private var appDelegate: FieldNotesAppDelegate to the App. Remove any root-view-controller setup from application(_:didFinishLaunchingWithOptions:), which should now just return true.

  3. Remove storyboard launch keys. Delete Main.storyboard, the UIMainStoryboardFile key, and the UISceneStoryboardFile entry inside the scene manifest. If the scene delegate created the window in scene(_:willConnectTo:options:), remove that method: SwiftUI now creates the window. The scene delegate can stay for other scene events, as FieldNotesSceneDelegate does.

  4. Move life-cycle code to ScenePhase. Replace sceneDidEnterBackground(_:) and similar handlers with .onChange(of: scenePhase) in the App or in a view.

UIKit screens that aren’t rewritten yet stay reachable through UIViewControllerRepresentable, covered on UIKit and AppKit Essentials, and Mixing Them with SwiftUI.

// FieldNotes/FieldNotesApp.swift, the minimal first step of the migration
import SwiftUI

@main
struct FieldNotesApp: App {
    @UIApplicationDelegateAdaptor private var appDelegate: FieldNotesAppDelegate

    var body: some Scene {
        WindowGroup {
            LegacyRootView()   // wraps the old root UIViewController
        }
    }
}

State Restoration with @SceneStorage

When the system terminates a backgrounded app, the person should come back to exactly where they were: same notebook, same note, same scroll position. SwiftUI’s @SceneStorage property wrapper stores small values per scene. It is saved with the scene’s session and restored when the system recreates that scene. Two FieldNotes windows on iPad therefore restore their own selections independently. It supports Bool, Int, Double, String, URL, Data and RawRepresentable types backed by Int or String, and their optionals. For anything larger, store an identifier and look the value up:

// FieldNotes/ContentView.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
import FieldNotesUI

struct ContentView: View {
    @Environment(NoteStore.self) private var store
    @SceneStorage("ContentView.selectedNotebookID") private var storedNotebookID: String?
    @SceneStorage("ContentView.selectedNoteID") private var storedNoteID: String?
    @State private var notebookID: Notebook.ID?
    @State private var noteID: Note.ID?

    var body: some View {
        NavigationSplitView {
            NotebookList(selection: $notebookID)
        } content: {
            NoteList(notebookID: notebookID, selection: $noteID)
        } detail: {
            if let note = store.notes.first(where: { $0.id == noteID }) {
                NoteDetail(note: note)
            } else {
                ContentUnavailableView("Select a Note", systemImage: "note.text")
            }
        }
        .onAppear {
            notebookID = storedNotebookID.flatMap(UUID.init(uuidString:))
            noteID = storedNoteID.flatMap(UUID.init(uuidString:))
        }
        .onChange(of: notebookID) { _, newValue in storedNotebookID = newValue?.uuidString }
        .onChange(of: noteID) { _, newValue in storedNoteID = newValue?.uuidString }
    }
}

@SceneStorage is for UI state, not data: the system discards it when the person explicitly closes a scene (for example by swiping it away in the app switcher), and it is never shared between scenes. Keep secrets and anything that must survive out of it. App-wide preferences belong in @AppStorage, and notes themselves in the store. All three are compared on Preferences, Files and Documents. UIKit’s equivalent is to return an NSUserActivity from the scene delegate’s stateRestorationActivity(for:) and read it back from session.stateRestorationActivity in scene(_:willConnectTo:options:). AppKit restores window frames and restorable state automatically once the delegate returns true from applicationSupportsSecureRestorableState(_:), as shown above.

Learning More

Apple’s SwiftUI Concepts tutorial on scenes builds a small multi-window app step by step, and is a good companion to this page before moving on to App Architecture the Apple Way.