Understanding the SwiftUI App Lifecycle: `@main`, `Scene`, and `WindowGroup`
Every iOS developer knows viewDidLoad and applicationDidBecomeActive — but those belong to UIKit. When you write a
SwiftUI app, the lifecycle model is entirely different, and if you try to port UIKit habits into it, you’ll end up with
code that fires unexpectedly, misses background transitions, or simply doesn’t compile in Swift 6.
This post covers the complete SwiftUI lifecycle from the @main entry point through scene phases, state restoration,
and background tasks. It assumes you’re already comfortable with
SwiftUI state management. Multi-platform details (macOS, iPad multi-window)
are touched on but not exhaustively covered — those deserve their own posts.
Contents
- The Problem
- The
@mainEntry Point and theAppProtocol - Scene Types
- Observing Lifecycle Events with
scenePhase - Bridging to UIKit with
UIApplicationDelegateAdaptor - Per-Scene State Restoration with
@SceneStorage - Background Tasks
- Multi-Window Support on iPad and macOS
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Imagine you’re building Pixar Film Tracker — an app that fetches the latest Pixar releases and caches them locally. When the app becomes active, you want to refresh the list. When it goes to the background, you want to save unsaved state and cancel non-critical network work.
A developer coming from UIKit might reach for .onAppear on the root view:
struct ContentView: View {
@StateObject private var viewModel = FilmLibraryViewModel()
var body: some View {
FilmListView(films: viewModel.films)
.onAppear {
// ❌ This fires every time the view appears,
// not just when the app becomes active.
Task { await viewModel.refreshFilms() }
}
}
}
This approach has three problems. First, .onAppear fires whenever the view is pushed onto the navigation stack or
presented as a sheet — not just on app launch or foreground transition. Second, it gives you no way to detect the
background transition, so you can’t cancel tasks or save state. Third, if you have multiple scenes open on iPad, each
scene’s root view fires .onAppear independently, potentially triggering duplicate refreshes.
The correct tool for lifecycle observation in SwiftUI is scenePhase, and the correct entry point for app-level setup
is the App protocol.
The @main Entry Point and the App Protocol
In a UIKit app, execution begins at UIApplicationMain and flows through AppDelegate. In a SwiftUI app, @main marks
a type conforming to the App protocol as the entry point.
import SwiftUI
@main
struct PixarFilmTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
The App protocol requires a single body property returning some Scene. This is the scene graph — the top-level
description of every window or window group your app can display. The system reads this description and instantiates the
appropriate windows based on context (iPhone, iPad, macOS, visionOS).
@main is a Swift attribute (introduced in SE-0281) that designates the program’s entry point. Only one type in your
module can carry @main.
Note: There is no
AppDelegateby default. If you need one — for push notification registration, third-party SDK setup, or otherUIApplicationDelegatecallbacks — you add it explicitly withUIApplicationDelegateAdaptor. See Bridging to UIKit withUIApplicationDelegateAdaptorbelow.
You can inject environment objects and app-wide dependencies directly from App:
@main
struct PixarFilmTrackerApp: App {
// App-scoped state, shared across all scenes
@StateObject private var filmLibrary = FilmLibrary()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(filmLibrary)
}
}
}
@StateObject in the App struct lives for the entire lifetime of the application process, making it the right place
for app-level singletons.
Scene Types
The body of an App is composed of Scene values. SwiftUI
ships four built-in scene types:
WindowGroup
WindowGroup is the standard container for most apps.
On iPhone it manages a single window; on iPad and macOS it supports multiple simultaneous windows, each running an
independent instance of the root view hierarchy.
WindowGroup {
FilmLibraryView()
}
You can give a WindowGroup an identifier when you need to open specific windows programmatically:
WindowGroup("Film Detail", id: "film-detail", for: Film.ID.self) { $filmID in
FilmDetailView(filmID: filmID)
}
DocumentGroup
DocumentGroup is for document-based apps — apps
that create, open, and save files. It wires up the system’s document browser UI and manages file coordination
automatically.
DocumentGroup(newDocument: StoryboardDocument()) { file in
StoryboardEditorView(document: file.$document)
}
Settings (macOS only)
Settings renders the macOS Preferences/Settings window.
Xcode and other macOS apps use this to provide a dedicated settings pane accessible from the application menu.
#if os(macOS)
Settings {
PixarFilmTrackerSettings()
}
#endif
MenuBarExtra (macOS only)
MenuBarExtra adds an item to the macOS menu bar.
Useful for utility apps that live in the status area.
#if os(macOS)
MenuBarExtra("Pixar Tracker", systemImage: "film") {
MenuBarQuickView()
}
#endif
Observing Lifecycle Events with scenePhase
The scenePhase environment value exposes the current
phase of a scene. It has three cases:
| Phase | Meaning |
|---|---|
.active | The scene is visible and interactive |
.inactive | The scene is visible but not interactive (e.g., app switcher, incoming call) |
.background | The scene is not visible and will soon be suspended |
You observe scenePhase with the .onChange(of:) modifier. Where you attach it matters:
- On the
Appstruct: receives the aggregate phase of all scenes. The app is.activeif any scene is active; it’s.backgroundonly when all scenes are backgrounded. - On a
WindowGroup: receives the aggregate phase for that window group. - On a view: receives the phase for the nearest enclosing scene.
For app-level lifecycle work, attach the observer directly in the App struct:
@main
struct PixarFilmTrackerApp: App {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var filmLibrary = FilmLibrary()
// Keep a reference to cancel on background
@State private var refreshTask: Task<Void, Never>?
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(filmLibrary)
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active:
// App became visible and interactive — resume or start work
refreshTask = Task {
await filmLibrary.refreshIfStale()
}
case .background:
// App is backgrounded — cancel non-essential tasks, save state
refreshTask?.cancel()
filmLibrary.saveUnsavedChanges()
case .inactive:
// App is transitioning (app switcher, incoming call) — usually no action needed
break
@unknown default:
break
}
}
}
}
The @unknown default case is mandatory for exhaustive switches on non-frozen enums like ScenePhase. The compiler
will warn you if you omit it — and it protects against future phases Apple might add.
Warning: Avoid starting heavy work in
.inactive. This phase is transient and fires frequently during interactions that don’t result in a background transition (e.g., the user pulls down Notification Center). Reserve actual work for.activeand cleanup for.background.
Bridging to UIKit with UIApplicationDelegateAdaptor
Some tasks still require UIApplicationDelegate — push notification token registration being the most common. For those
cases, add a delegate and register it with
UIApplicationDelegateAdaptor:
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Third-party SDK setup, Crashlytics, etc.
FirebaseApp.configure()
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
// Send token to your push notification server
PushRegistrationService.shared.sendToken(deviceToken)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("APNs registration failed: \(error)")
}
}
@main
struct PixarFilmTrackerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Note: When you provide a
UIApplicationDelegateAdaptor, SwiftUI still owns the app lifecycle — your delegate is an observer, not the primary controller. Do not callUIApplicationMainyourself or return a customNSPrincipalClass.
If you also need UIWindowSceneDelegate — for fine-grained scene-level callbacks — you can return a custom scene
delegate class from application(_:configurationForConnecting:options:) in your AppDelegate. However, this is rarely
necessary in pure SwiftUI apps.
Per-Scene State Restoration with @SceneStorage
@SceneStorage persists lightweight UI state
(selected tab, scroll position, active film ID) on a per-scene basis. Unlike @AppStorage which is global,
@SceneStorage is scoped to its enclosing scene — making it the right tool for iPad apps with multiple windows open
simultaneously.
struct FilmLibraryView: View {
// Automatically saved and restored per scene
@SceneStorage("selectedFilmID") private var selectedFilmID: String?
@SceneStorage("selectedTab") private var selectedTab: String = "library"
var body: some View {
TabView(selection: $selectedTab) {
FilmListView(selectedFilmID: $selectedFilmID)
.tabItem { Label("Library", systemImage: "film") }
.tag("library")
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag("search")
}
}
}
The system writes @SceneStorage values to disk automatically before the scene enters the background and restores them
when the scene reconnects. The storage key must be unique within the app — collisions will cause one scene to overwrite
another’s state.
Warning:
@SceneStoragesupports only a limited set of types:Bool,Int,Double,String,URL,Data, andRawRepresentabletypes whose raw values are one of the above. For complex model objects, serialize toDataor persist via SwiftData/UserDefaults and store only a reference (like an ID) in@SceneStorage.
Background Tasks
For work that must complete after the app enters the background, use the
BackgroundTask API introduced in iOS 16 alongside
BGTaskScheduler.
Apple Docs:
BackgroundTask— SwiftUI |BGTaskScheduler— BackgroundTasks
Register and schedule background app refresh from within the App struct:
@main
struct PixarFilmTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// iOS 16+ declarative background task handler
.backgroundTask(.appRefresh("com.pixartracker.refresh")) {
// This closure runs in the background.
// You have a limited budget (~30 seconds).
let library = FilmLibrary()
await library.refreshFromNetwork()
// Schedule the next refresh
scheduleAppRefresh()
}
}
private func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.pixartracker.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
try? BGTaskScheduler.shared.submit(request)
}
}
You must declare the task identifier in Info.plist under BGTaskSchedulerPermittedIdentifiers and enable the
Background Modes capability with Background fetch checked.
Note: The system decides when background tasks actually run — your
earliestBeginDateis a floor, not a guarantee. In development, usee -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.pixartracker.refresh"]in the Xcode debugger to trigger the task immediately.
Multi-Window Support on iPad and macOS
On iPad (iPadOS 16+) and macOS, WindowGroup can spawn multiple simultaneous window instances. Use the
openWindow environment action to
open a specific window from your code:
struct FilmDetailView: View {
let film: Film
@Environment(\.openWindow) private var openWindow
@Environment(\.supportsMultipleWindows) private var supportsMultipleWindows
var body: some View {
VStack {
// ...
if supportsMultipleWindows {
Button("Open in New Window") {
openWindow(value: film.id)
}
}
}
}
}
The supportsMultipleWindows environment value is false on iPhone and true on iPad and macOS — use it to
conditionally show multi-window affordances rather than hardcoding platform checks.
Advanced Usage
Handoff and Spotlight Integration
To support Handoff (continuing an activity on another device), call
NSUserActivity from your views and handle it in
onContinueUserActivity:
struct FilmDetailView: View {
let film: Film
var body: some View {
FilmContentView(film: film)
.userActivity("com.pixartracker.viewFilm") { activity in
// Advertise current activity for Handoff
activity.title = film.title
activity.userInfo = ["filmID": film.id.uuidString]
activity.isEligibleForHandoff = true
activity.isEligibleForSearch = true // Spotlight
}
}
}
@main
struct PixarFilmTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity("com.pixartracker.viewFilm") { activity in
guard let filmIDString = activity.userInfo?["filmID"] as? String,
let filmID = UUID(uuidString: filmIDString) else { return }
NavigationModel.shared.navigateToFilm(id: filmID)
}
}
}
}
UIWindowSceneDelegate for Fine-Grained Scene Events
For per-scene events not exposed through scenePhase (such as windowScene(_:userDidAcceptCloudKitShareWith:)), you
can provide a custom UIWindowSceneDelegate:
final class SceneDelegate: NSObject, UIWindowSceneDelegate {
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShareMetadata
) {
// Handle CloudKit share acceptance
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let config = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
config.delegateClass = SceneDelegate.self
return config
}
}
This is an escape hatch — the vast majority of SwiftUI lifecycle work can be done without a scene delegate.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Run code on app launch (one-time setup) | Use the App struct’s init() or a .task modifier on the root view’s outer container |
| React to foreground/background transitions | Use .onChange(of: scenePhase) on the App struct |
| Restore UI state per scene (selected tab, row) | Use @SceneStorage |
| Global app state (user session, feature flags) | Use @StateObject in the App struct, injected via .environmentObject |
| APNs registration, third-party SDK setup | Use UIApplicationDelegateAdaptor |
| Work that must run in the background | Use the backgroundTask scene modifier + BGTaskScheduler |
.onAppear for lifecycle | Avoid for app-level lifecycle — use it only for view-level side effects like starting animations |
AppDelegate-first architecture in new SwiftUI apps | Avoid — the App protocol is the intended entry point; use UIApplicationDelegateAdaptor only for specific callbacks |
Summary
- The
@mainattribute marks yourApp-conforming struct as the program entry point — there is noAppDelegateby default. WindowGroupis the standard scene for most apps;DocumentGroup,Settings, andMenuBarExtraserve specialized roles on macOS.scenePhaseis the correct way to observe foreground/background transitions in SwiftUI — not.onAppear.- Attach
scenePhaseobservers on theAppstruct (not individual views) to track aggregate app state. - Use
@SceneStoragefor lightweight per-scene UI state that should survive backgrounding and multi-window usage. - For APNs, Crashlytics, or other
UIApplicationDelegatecallbacks, addUIApplicationDelegateAdaptor— it doesn’t cede lifecycle control to UIKit. - iOS 16+
backgroundTaskscene modifiers give you a clean, declarative way to schedule and run background app refresh tasks.
Push notifications are the natural next step — they require UIApplicationDelegateAdaptor for token registration and
deep UNUserNotificationCenterDelegate integration. Head over to
Push Notifications in Swift for the full implementation.