WWDC Quick Look 💓 By SwiftGGTeam
Make your UIKit app more flexible

Make your UIKit app more flexible

Watch original video

Highlight

Build a flexible UIKit app in three layers.

Core content

Many older UIKit projects still run on the AppDelegate lifecycle. That design was enough at first: one app, one window, three events to handle — launch, background, terminate. But once iPad multi-window, Mac Catalyst, AirPlay external displays, and visionOS multi-scene came along, AppDelegate’s global state started to clash. When window A goes to the background, window B is still in the foreground — should the timer pause? When an external display attaches, should the main UI pop up? In AppDelegate you have to write all the glue yourself.

After iOS 26, the next major version will require the UIScene lifecycle for apps built with the latest SDK (02:19). In this session Apple breaks flexibility into three layers. The bottom layer is the Scene: each Scene is a separate instance of the app’s UI, with its own view controllers, state restoration, and window geometry. The middle layer is container view controllers: UISplitViewController and UITabBarController handle navigation across iPhone, iPad, Mac, and visionOS. The top layer is the adaptive APIs: Safe Area, Layout Margins Guide, and avoidance of the window control buttons keep your UI from being covered at any size.

Detailed content

Scene configuration is decided by the AppDelegate (03:02). An app can have several Scene types — for example, a normal main UI and an AirPlay external display. The AppDelegate returns different configurations from configurationForConnectingSceneSession based on the session role:

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication,
                     configurationForConnecting sceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {

        if sceneSession.role == .windowExternalDisplayNonInteractive {
            return UISceneConfiguration(name: "Timer Scene",
                                        sessionRole: sceneSession.role)
        } else {
            return UISceneConfiguration(name: "Main Scene",
                                        sessionRole: sceneSession.role)
        }
    }
}

Key points:

  • sceneSession.role tells the scenes apart. .windowExternalDisplayNonInteractive means a read-only external display such as AirPlay.
  • The name in the returned UISceneConfiguration must match the Scene configuration declared in Info.plist.
  • One app can register several configurations and route by role.

The Scene’s window is created by the SceneDelegate (03:30). In sceneWillConnectTo, attach the window to the Scene and inject the model into the root view controller:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    var timerModel = TimerModel()

    func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {

        let windowScene = scene as! UIWindowScene
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = TimerViewController(model: timerModel)
        window.makeKeyAndVisible()
        self.window = window
    }
}

Key points:

  • timerModel is Scene-level state. Each window has its own timer; it is no longer a global singleton.
  • If the Scene configuration names a storyboard, the window is created for you and you write nothing.
  • UIWindow(windowScene:) binds the window to the current Scene, so windows do not bleed into each other.

State restoration uses NSUserActivity (04:09). When a Scene goes to the background, the system calls stateRestorationActivity(for:) to grab a userActivity and persist it. When the Scene reconnects, restoreInteractionStateWith puts the state back:

func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
    let userActivity = NSUserActivity(activityType: "com.example.timer.ui-state")
    userActivity.userInfo = ["selectedTimeFormat": timerModel.selectedTimeFormat]
    return userActivity
}

func scene(_ scene: UIScene, restoreInteractionStateWith userActivity: NSUserActivity) {
    if let selectedTimeFormat = userActivity.userInfo?["selectedTimeFormat"] as? String {
        timerModel.selectedTimeFormat = selectedTimeFormat
    }
}

Key points:

  • The activityType must match the one declared in Info.plist, or the system will not persist it.
  • Put only serializable UI state in userInfo — selection, scroll position, text input.
  • Model-layer data (tasks, documents) belongs in your own storage. userActivity only handles UI.

SplitView column width and the layout environment (04:46). The new splitViewControllerLayoutEnvironment trait tells you whether the SplitView is expanded or collapsed, so you can switch the cell style:

override func updateConfiguration(using state: UICellConfigurationState) {
    if state.traitCollection.splitViewControllerLayoutEnvironment == .collapsed {
        accessories = [.disclosureIndicator()]
    } else {
        accessories = []
    }
}

Key points:

  • .collapsed means the SplitView columns are stacked into a single navigation stack, so a disclosure tells the user they can drill down.
  • .expanded means the columns are side by side; once a cell is selected the content shows in the secondary column and no chevron is needed.
  • This trait is part of the trait collection, so the cell reconfigures itself when the trait changes.

Avoiding the window control buttons (13:04). iPadOS 26 adds three window control buttons: close, minimize, and arrange. layoutGuide(for: .margins(cornerAdaptation:)) returns a layout guide that steers around the window controls:

let contentGuide = containerView.layoutGuide(for: .margins(cornerAdaptation: .horizontal))

NSLayoutConstraint.activate([
    contentView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
    contentView.leadingAnchor.constraint(equalTo: contentGuide.leadingAnchor),
    contentView.bottomAnchor.constraint(equalTo: contentGuide.bottomAnchor),
    contentView.trailingAnchor.constraint(equalTo: contentGuide.trailingAnchor)
])

Key points:

  • .horizontal corner adaptation suits content like a top bar; it pulls in from the trailing edge to avoid the window controls.
  • System components like UINavigationBar already adapt. You only need to do this for custom top UI.
  • Skip this and your custom buttons will sit underneath the window controls.

Pause expensive rendering during resize (14:44). For games and other render-heavy scenes, you do not want to regenerate assets every frame while the user drags a window edge:

func windowScene(
    _ windowScene: UIWindowScene,
    didUpdateEffectiveGeometry previousGeometry: UIWindowScene.Geometry) {

    let geometry = windowScene.effectiveGeometry
    let sceneSize = geometry.coordinateSpace.bounds.size

    if !geometry.isInteractivelyResizing && sceneSize != previousSceneSize {
        previousSceneSize = sceneSize
        gameAssetManager.updateAssets(sceneSize: sceneSize)
    }
}

Key points:

  • isInteractivelyResizing is true while the user is dragging an edge and false once they release.
  • Update assets once, on release, so you do not rebuild textures and re-lay-out repeatedly during the drag.
  • Good for games, video players, and custom rendering UI.

Key takeaways

  • What to do: Migrate from the AppDelegate lifecycle to the Scene lifecycle.

    • Why it is worth doing: The next major version after iOS 26 will require it, so migrate early to avoid a deadline rush. Multi-window, state restoration, and AirPlay external displays all depend on Scenes.
    • How to start: Read TN3187. Make the smallest change first — move window creation and lifecycle events from AppDelegate to SceneDelegate without touching any UI code. Once that runs, take on multi-window and stateRestorationActivity.
  • What to do: Add an Inspector column to your SplitView.

    • Why it is worth doing: Preview, Mail, and Files on iPadOS 26 all use an Inspector to show metadata for the selected item. When expanded it sits next to the secondary column; when collapsed it becomes a sheet automatically, with no adaptation code from you.
    • How to start: Call splitViewController.setViewController(_:for: .inspector) to register the inspector view controller, then splitViewController.show(.inspector) to reveal it. Pair this with the splitViewControllerLayoutEnvironment trait to tighten the inspector when collapsed.
  • What to do: Add an isInteractivelyResizing check to render-heavy scenes.

    • Why it is worth doing: Users drag windows around all the time on iPad multi-window and Mac. Rebuilding assets on every geometry change stutters; one update on release is enough.
    • How to start: Implement windowScene(_:didUpdateEffectiveGeometry:) in your SceneDelegate and only fire the expensive work when geometry.isInteractivelyResizing == false. Works for game textures, video decoding, and PDF rendering.
  • What to do: Use Layout Margins Guide instead of hand-written margin numbers.

    • Why it is worth doing: Each platform has its own standard margin. Hard-coded 16 or 20 looks wrong on visionOS or Mac Catalyst. Layout Margins Guide tracks Safe Area and the window controls for you.
    • How to start: Anchor constraints to view.layoutMarginsGuide and align all content to it. For window control scenes also use view.layoutGuide(for: .margins(cornerAdaptation: .horizontal)).

Comments

GitHub Issues · utterances