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

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 AppDelegate methods in iOS 13. After iOS 26, apps that have not adopted UISceneDelegate may 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 your AppDelegate instead. 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 didFinishLaunchingscene(_:willConnectTo:options:)

Methods that remain on AppDelegate because they are process-level:

  • application(_:didFinishLaunchingWithOptions:) — process-level setup
  • application(_:didRegisterForRemoteNotificationsWithDeviceToken:) — push tokens
  • application(_:didReceiveRemoteNotification:fetchCompletionHandler:) — silent push
  • application(_:configurationForConnecting:options:) — scene session creation
  • application(_:didDiscardSceneSessions:) — scene session cleanup

Warning: If you implement both the AppDelegate and SceneDelegate versions of a lifecycle method (e.g., applicationDidEnterBackground and sceneDidEnterBackground), only the SceneDelegate version gets called when scenes are connected. The AppDelegate fallback 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 your SceneDelegate to persist the user’s current state when a scene is disconnected. The system stores this activity and passes it back via session.stateRestorationActivity when 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 UISceneDelegate cannot 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 SceneDelegate may 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 @Published above) to keep scenes decoupled.

When to Use (and When Not To)

ScenarioRecommendation
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 needMigrate anyway. The API will be required after iOS 26, and the migration is straightforward for single-scene apps.
iPadOS app in the App StoreMigrate and enable multi-window. Users expect it, and iPadOS 26 window controls make it shine.
Pure SwiftUI app using @main AppYou already have scene support via WindowGroup. No UIKit migration needed.
App using both UIKit and SwiftUIAdopt 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 AppDelegate as the owner of window management and UI lifecycle events. AppDelegate still handles process-level concerns like push tokens and scene session configuration.
  • UIWindowScene is 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 NSUserActivity to 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 SceneDelegate properties. 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.