UIKit Scene Lifecycle: Migrating from AppDelegate to UISceneDelegate
If your UIKit app still boots from a monolithic AppDelegate with a single UIWindow property, the clock is ticking.
Apple has signaled that scene-based lifecycle adoption will be required after iOS 26, and multi-window support on iPadOS
is no longer a “nice to have” — it is an expectation. The migration is not difficult, but getting the mental model
right before you start saves hours of debugging disconnected scenes and missing windows.
This guide walks you through the full migration from an AppDelegate-only architecture to UISceneDelegate, explains
the lifecycle event mapping, covers multi-window support on iPadOS, and introduces the new iPadOS 26 window controls. We
will not cover SwiftUI’s App protocol or WindowGroup — those are detailed in
Understanding the SwiftUI App Lifecycle.
Contents
- The Problem
- Understanding Scenes: The New Unit of UI
- Step-by-Step Migration
- Lifecycle Event Mapping
- Multi-Window Support on iPadOS
- iPadOS 26 Window Controls
- Advanced Usage and Edge Cases
- When to Use (and When Not To)
- Summary
The Problem
Consider a classic AppDelegate-based UIKit app — say, a Pixar movie catalog. All window management and lifecycle
handling lives in one place:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let catalogVC = MovieCatalogViewController()
window?.rootViewController = UINavigationController(rootViewController: catalogVC)
window?.makeKeyAndVisible()
return true
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Save the user's current browsing position
MovieSessionStore.shared.persist()
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Refresh movie data on resume
MovieDataService.shared.refreshIfStale()
}
}
This works fine for a single-window iPhone app. But it breaks down the moment you want to support multiple windows on
iPadOS, because AppDelegate owns exactly one UIWindow. There is no concept of independent UI instances. When a user
drags your app into Split View or opens a new window from the shelf, the system needs a way to create and manage
separate UI hierarchies — and AppDelegate has no mechanism for that.
Starting with iOS 13, Apple introduced the scene-based lifecycle to solve this. Each UIScene (specifically
UIWindowScene) represents an independent instance of your app’s UI, with its own lifecycle and its own window. The
AppDelegate still exists, but its role shrinks to process-level concerns: push token registration, handling URL
schemes, and configuring new scene sessions.
Note: Apple first deprecated the window-management
AppDelegatemethods in iOS 13. After iOS 26, apps that have not adoptedUISceneDelegatemay no longer receive lifecycle callbacks reliably. The writing is on the wall — migrate now.
Understanding Scenes: The New Unit of UI
Before touching any code, you need to internalize the new hierarchy:
UIApplication (process-level, singleton)
└─ UISceneSession (persisted metadata about a scene)
└─ UIScene / UIWindowScene (a running instance of your UI)
└─ UIWindow (one or more per scene)
└─ UIViewController hierarchy
Think of it like Pixar’s animation pipeline. The UIApplication is the studio itself — one studio, always running.
Each UISceneSession is a film project (Toy Story, Finding Nemo) with its own metadata and state. The UIWindowScene
is the active rendering session for that film, and the UIWindow instances are the individual render outputs (main
monitor, secondary display). Multiple films can be in production simultaneously, each independent.
The key insight: lifecycle events that used to be process-level are now scene-level. “Did enter background” no longer means the entire app went to the background — it means one scene went to the background while others may still be in the foreground.
Apple Docs:
UIScene— UIKit
Step-by-Step Migration
The migration has three parts: configuring the scene manifest, creating a scene delegate, and moving lifecycle code out
of AppDelegate.
1. Add the Scene Configuration to Info.plist
Your app needs to declare that it supports scenes. Add the UIApplicationSceneManifest entry to your Info.plist:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<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>
Set UIApplicationSupportsMultipleScenes to false for now. We will flip it to true when we add multi-window support
later.
Tip: If you prefer code-based configuration over Info.plist, you can return the configuration from
application(_:configurationForConnecting:options:)in yourAppDelegateinstead. Both approaches are valid; the Info.plist approach is simpler for most apps.
2. Create the SceneDelegate
Create a new file, SceneDelegate.swift. This is where your window setup and per-scene lifecycle handling now live:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
let catalogVC = MovieCatalogViewController()
window.rootViewController = UINavigationController(rootViewController: catalogVC)
window.makeKeyAndVisible()
self.window = window
}
func sceneDidEnterBackground(_ scene: UIScene) {
MovieSessionStore.shared.persist()
}
func sceneWillEnterForeground(_ scene: UIScene) {
MovieDataService.shared.refreshIfStale()
}
}
Notice the window is now created with UIWindow(windowScene:) instead of UIWindow(frame:). This binds the window to a
specific scene, which is essential for multi-window support.
3. Trim the AppDelegate
Your AppDelegate sheds its window management and lifecycle responsibilities. What remains is process-level setup:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
// No more `var window: UIWindow?` here
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Process-level setup only: logging, analytics, push registration
UNUserNotificationCenter.current().delegate = NotificationHandler.shared
return true
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
func application(
_ application: UIApplication,
didDiscardSceneSessions sceneSessions: Set<UISceneSession>
) {
// Clean up resources tied to discarded scenes
for session in sceneSessions {
MovieSessionStore.shared.removeSession(session.persistentIdentifier)
}
}
}
The configurationForConnecting method is called every time the system creates a new scene. If you declared your
configuration in Info.plist, you can return a matching UISceneConfiguration by name. The didDiscardSceneSessions
method is your chance to clean up any data stored for scenes the user has swiped away.
Lifecycle Event Mapping
One of the trickiest parts of the migration is knowing which AppDelegate methods map to which SceneDelegate methods.
Here is the complete mapping:
| AppDelegate (deprecated for UI lifecycle) | SceneDelegate equivalent |
|---|---|
applicationDidBecomeActive(_:) | sceneDidBecomeActive(_:) |
applicationWillResignActive(_:) | sceneWillResignActive(_:) |
applicationDidEnterBackground(_:) | sceneDidEnterBackground(_:) |
applicationWillEnterForeground(_:) | sceneWillEnterForeground(_:) |
Window creation in didFinishLaunching | scene(_:willConnectTo:options:) |
Methods that remain on AppDelegate because they are process-level:
application(_:didFinishLaunchingWithOptions:)— process-level setupapplication(_:didRegisterForRemoteNotificationsWithDeviceToken:)— push tokensapplication(_:didReceiveRemoteNotification:fetchCompletionHandler:)— silent pushapplication(_:configurationForConnecting:options:)— scene session creationapplication(_:didDiscardSceneSessions:)— scene session cleanup
Warning: If you implement both the
AppDelegateandSceneDelegateversions of a lifecycle method (e.g.,applicationDidEnterBackgroundandsceneDidEnterBackground), only theSceneDelegateversion gets called when scenes are connected. TheAppDelegatefallback only fires if no scene delegate is present. This is a common source of “my callback stopped working” bugs during migration.
Multi-Window Support on iPadOS
Once your scene architecture is in place, enabling multi-window support is straightforward. Flip the manifest flag and handle per-scene state.
Enable Multiple Scenes
In your Info.plist, change UIApplicationSupportsMultipleScenes to true:
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
Request New Scenes Programmatically
Users can create new windows via the system shelf, but you can also request them in code. For example, opening a movie detail window from the catalog:
func openMovieInNewWindow(movie: PixarMovie) {
let activity = NSUserActivity(activityType: "com.cocoabytes.movieDetail")
activity.userInfo = ["movieID": movie.id]
activity.title = movie.title
UIApplication.shared.requestSceneSessionActivation(
nil, // nil = create a new session
userActivity: activity,
options: nil,
errorHandler: { error in
print("Failed to open new window: \(error.localizedDescription)")
}
)
}
Restore Scene State from User Activity
In your SceneDelegate, inspect the connection options and the session’s state restoration activity to determine what
content to show:
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
// Check for a user activity requesting a specific movie
let activity = connectionOptions.userActivities.first
?? session.stateRestorationActivity
if let movieID = activity?.userInfo?["movieID"] as? String {
let detailVC = MovieDetailViewController(movieID: movieID)
window.rootViewController = UINavigationController(rootViewController: detailVC)
} else {
let catalogVC = MovieCatalogViewController()
window.rootViewController = UINavigationController(rootViewController: catalogVC)
}
window.makeKeyAndVisible()
self.window = window
}
Tip: Implement
stateRestorationActivity(for:)on yourSceneDelegateto persist the user’s current state when a scene is disconnected. The system stores this activity and passes it back viasession.stateRestorationActivitywhen the scene reconnects. This is how you survive the system reclaiming a background scene’s memory without losing the user’s place.
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
let activity = NSUserActivity(activityType: "com.cocoabytes.movieDetail")
activity.userInfo = ["movieID": currentlyDisplayedMovieID]
return activity
}
Apple Docs:
UISceneSession— UIKit
iPadOS 26 Window Controls
iPadOS 26 introduced significant improvements to window management that scene-based apps get for free. These controls give users more flexibility in arranging your app’s windows, and you can customize the behavior.
Window Snapping and Tiling
iPadOS 26 provides system-level window tiling. Your scene-based app participates automatically, but you can influence the behavior by setting preferred size restrictions:
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
// Set minimum and maximum size for windowed mode
let geometryPreferences = UIWindowScene.GeometryPreferences.iOS(
interfaceOrientations: .all
)
windowScene.requestGeometryUpdate(geometryPreferences)
// Configure size restrictions for the scene
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 300)
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 1200, height: 900)
// ... window setup continues
}
Responding to Window State Changes
Track how the user arranges your scenes by observing the scene’s effectiveGeometry:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func windowScene(
_ windowScene: UIWindowScene,
didUpdate previousCoordinateSpace: UICoordinateSpace,
interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation,
traitCollection previousTraitCollection: UITraitCollection
) {
// Adapt layout based on the new window dimensions
let currentSize = windowScene.coordinateSpace.bounds.size
let isCompact = currentSize.width < 500
if let navController = window?.rootViewController as? UINavigationController,
let catalogVC = navController.topViewController as? MovieCatalogViewController {
catalogVC.updateLayout(compact: isCompact)
}
}
}
Note: The iPadOS 26 window controls build on top of the scene lifecycle. Apps that have not adopted
UISceneDelegatecannot participate in these features. This is another strong reason to migrate sooner rather than later.
Advanced Usage and Edge Cases
Scene Destruction vs. Disconnection
A critical distinction that trips up even experienced developers: when a scene moves to the background and the system
reclaims its memory, the scene is disconnected — not destroyed. The UISceneSession persists, and when the user
returns, the system calls scene(_:willConnectTo:options:) again with the same session.
func sceneDidDisconnect(_ scene: UIScene) {
// The scene's UIWindow and view hierarchy have been released.
// The UISceneSession still exists -- the user can return to this scene.
// Release any resources tied to the scene's UI, but keep session data.
print("Scene disconnected: \(scene.session.persistentIdentifier)")
}
This means you should not treat sceneDidDisconnect as “the user closed the app.” Think of it like Woody being put back
in the toy box in Toy Story — he is not gone, he is just waiting to be played with again.
Handling External Displays
Each external display gets its own UIWindowScene. You can differentiate them by checking the session role:
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
switch connectingSceneSession.role {
case .windowApplication:
return UISceneConfiguration(
name: "Default Configuration",
sessionRole: .windowApplication
)
case .windowExternalDisplayNonInteractive:
return UISceneConfiguration(
name: "External Display Configuration",
sessionRole: .windowExternalDisplayNonInteractive
)
default:
return UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
}
Apple Docs:
UISceneSession.Role— UIKit
Coordinating Across Scenes
When multiple scenes are active, they need a shared data layer. Avoid storing mutable state on the SceneDelegate —
use a shared model or service layer instead:
// Shared across all scenes -- the single source of truth
class MovieLibrary {
static let shared = MovieLibrary()
@Published private(set) var favorites: [PixarMovie] = []
func toggleFavorite(_ movie: PixarMovie) {
if let index = favorites.firstIndex(where: { $0.id == movie.id }) {
favorites.remove(at: index)
} else {
favorites.append(movie)
}
// All scenes observing `favorites` update automatically
}
}
Each SceneDelegate reads from MovieLibrary.shared, so changes in one window are immediately reflected in all others
— just like how all the toys in Andy’s room share the same reality, even when they are in different parts of the house.
Warning: Be careful with singletons that hold UI state. A
SceneDelegatemay be deallocated when its scene disconnects. If a singleton holds a strong reference to a scene delegate or its view controllers, you will create a retain cycle. Use weak references or an observer pattern (like@Publishedabove) to keep scenes decoupled.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| New UIKit app targeting iOS 16+ | Adopt UISceneDelegate from day one. There is no reason to start with the legacy pattern. |
| Existing app with no multi-window need | Migrate anyway. The API will be required after iOS 26, and the migration is straightforward for single-scene apps. |
| iPadOS app in the App Store | Migrate and enable multi-window. Users expect it, and iPadOS 26 window controls make it shine. |
Pure SwiftUI app using @main App | You already have scene support via WindowGroup. No UIKit migration needed. |
| App using both UIKit and SwiftUI | Adopt UISceneDelegate for the UIKit layer and use UIKit / SwiftUI interop for bridging. |
| Extremely legacy codebase (iOS 12 support) | You cannot adopt scenes while supporting iOS 12. Plan your minimum deployment target bump first. |
Summary
- The scene-based lifecycle replaces
AppDelegateas the owner of window management and UI lifecycle events.AppDelegatestill handles process-level concerns like push tokens and scene session configuration. UIWindowSceneis the new unit of UI. Each scene has its own window, its own lifecycle, and its own state restoration activity. The mental shift from “one app, one window” to “one app, many scenes” is the hardest part of the migration.- Multi-window support on iPadOS follows naturally once you adopt scenes. Use
NSUserActivityto pass content between scene requests and to persist state for disconnected scenes. - iPadOS 26 window controls (tiling, snapping, size restrictions) only work with scene-based apps, adding urgency to the migration.
- Coordinate across scenes through a shared data layer, not through
SceneDelegateproperties. Keep scene delegates lightweight and disposable.
Apple has been nudging developers toward scenes since iOS 13 and the push in iOS 26 makes the direction clear. If you
are maintaining a UIKit codebase, this migration is the right next step. For bridging your scene-based UIKit app with
SwiftUI views, see UIKit / SwiftUI Interoperability. For the new Liquid Glass
visual effects and @Observable integration in UIKit, check out UIKit in iOS 26.