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

Make your UIKit app more flexible

元の動画を見る

Highlight

3 つのレイヤーで柔軟な UIKit アプリケーションを構築します。

主な内容

UIKit を使った既存プロジェクトの多くは、いまだに AppDelegate のライフサイクルを利用しています。当初はそれで十分でした。1 つのアプリに 1 つのウィンドウ、起動・バックグラウンド遷移・終了の 3 つだけを処理すればよかったからです。しかし、iPad のマルチウィンドウ、Mac Catalyst、AirPlay の外部ディスプレイ、visionOS のマルチシーンが登場してからは、AppDelegate のグローバルな状態管理がきしみ始めます。ウィンドウ A がバックグラウンドへ移ったとき、ウィンドウ B がまだフォアグラウンドにあるなら、Timer は止めるべきでしょうか。外部ディスプレイが接続されたとき、メイン画面を出すべきでしょうか。こうした問題は AppDelegate の中で自前のグルーコードとして書かなければなりませんでした。

iOS 26 以降の次のメジャーバージョンでは、最新 SDK でビルドするアプリに対して UIScene ライフサイクルが必須要件となります(02:19)。本セッションでは、Apple が柔軟性を 3 つのレイヤーに整理しています。最下層は Scene で、各 Scene はアプリ UI の独立したインスタンスとして、自分自身の View Controller、状態保存、ウィンドウのジオメトリ情報を持ちます。中間層はコンテナ View Controller で、UISplitViewController と UITabBarController が iPhone / iPad / Mac / visionOS をまたいだナビゲーション適合を担当します。最上層はアダプティブ API で、Safe Area、Layout Margins Guide、ウィンドウコントロールボタンの回避といった仕組みにより、UI がどんなサイズでも遮られないようにします。

詳細

Scene の構成は AppDelegate が決定する03:02)。1 つのアプリには複数の Scene タイプを定義できます。たとえば通常のメイン画面と AirPlay 用の外部ディスプレイなどです。AppDelegate は configurationForConnectingSceneSession の中で sessionRole に応じて異なる構成を返します。

@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)
        }
    }
}

ポイント:

  • sceneSession.role で Scene の用途を区別します。.windowExternalDisplayNonInteractive は AirPlay のような読み取り専用の外部ディスプレイを表します。
  • 返却する UISceneConfiguration の名前は、Info.plist で宣言した Scene 構成と一致させる必要があります。
  • 同一アプリに複数の構成を登録し、role でルーティングできます。

Scene のウィンドウは SceneDelegate が生成する03:30)。sceneWillConnectTo の中でウィンドウと Scene を関連付け、ルート 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
    }
}

ポイント:

  • timerModel は Scene 単位の状態であり、各ウィンドウが固有の Timer を持ちます。グローバルなシングルトンではありません。
  • Scene 構成で storyboard を指定している場合、ウィンドウは自動生成されるため自前で書く必要はありません。
  • UIWindow(windowScene:) でウィンドウを現在の Scene にバインドすることで、マルチウィンドウでも混線しません。

状態復元には NSUserActivity を使う04:09)。Scene がバックグラウンドへ入る際、システムは stateRestorationActivity(for:) を呼び出して userActivity を取得し、それを永続化します。Scene の再接続時には restoreInteractionStateWith を通じて状態を復元します。

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
    }
}

ポイント:

  • activityType は Info.plist で宣言した値と一致している必要があります。一致しない場合、システムは永続化しません。
  • userInfo にはシリアライズ可能な UI 状態のみを入れます。たとえば選択項目、スクロール位置、テキスト入力などです。
  • モデル層のデータ(タスク、ドキュメント)は独自のストレージへ保存すべきで、userActivity は UI のみを担当します。

SplitView の列幅とレイアウト環境04:46)。新しく追加された splitViewControllerLayoutEnvironment trait は、現在の SplitView が展開状態か折りたたみ状態かを伝えてくれます。これに応じてセルのスタイルを切り替えられます。

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

ポイント:

  • .collapsed は SplitView の列が単一カラムのナビゲーションスタックに圧縮された状態を示します。さらに先へ遷移できることを示すため disclosure 表示が必要です。
  • .expanded のときは複数カラムが横並びで表示されており、セル選択後にコンテンツが secondary 列に直接表示されるため、矢印は不要です。
  • この trait は trait collection の一部で、trait が変わるとセルが自動的に再構成されます。

ウィンドウコントロールボタンの回避13:04)。iPadOS 26 では、閉じる / 最小化 / 整列の 3 つのウィンドウコントロールボタンが追加されました。layoutGuide(for: .margins(cornerAdaptation:)) を使うと、ウィンドウコントロールを避けるレイアウトガイドが取得できます。

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)
])

ポイント:

  • .horizontal のコーナー適合は上部のバー的なコンテンツに向いており、trailing 側でウィンドウコントロールを避けます。
  • UINavigationBar などのシステムコンポーネントはすでに自動適合されているため、カスタムの上部 UI のみ手動対応が必要です。
  • この適合を書かないと、カスタムボタンがウィンドウコントロールに覆われてしまいます。

リサイズ中は重い描画を一時停止する14:44)。ゲームや重い再描画を伴うシーンでは、ユーザーがウィンドウの端をドラッグしている間、フレームごとにリソースを再生成する必要はありません。

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)
    }
}

ポイント:

  • isInteractivelyResizing はユーザーが端をドラッグしている間 true になり、手を離すと false になります。
  • 手を離した後で 1 度だけリソースを更新することで、ドラッグ中にテクスチャを再構築したりレイアウトをやり直したりするのを避けられます。
  • ゲーム、動画プレイヤー、カスタム描画 UI に向いています。

重要なヒント

  • やること: AppDelegate ライフサイクルから Scene ライフサイクルへ移行する。

    • なぜやる価値があるか: iOS 26 以降の次のメジャーバージョンで必須となるため、早めに移行することで締め切りに追われずに済みます。マルチウィンドウ、状態復元、AirPlay 外部ディスプレイなどの機能はすべて Scene に依存しています。
    • 始め方: TN3187 のテクニカルドキュメントを読み、最小限の変更からスタートします。ウィンドウの生成とライフサイクルイベントを AppDelegate から SceneDelegate に移し、UI コードは一切触れません。動作が確認できたら、マルチウィンドウや stateRestorationActivity といった発展的な機能を検討します。
  • やること: SplitView に Inspector カラムを追加する。

    • なぜやる価値があるか: iPadOS 26 の Preview、Mail、Files はいずれも Inspector で選択項目のメタデータを表示します。展開時には secondary カラムの隣に表示され、折りたたみ時には自動で Sheet になるため、自前で適合を書く必要がありません。
    • 始め方: splitViewController.setViewController(_:for: .inspector) で inspector の View Controller を登録し、splitViewController.show(.inspector) で表示します。splitViewControllerLayoutEnvironment trait と組み合わせ、collapsed 時には Inspector のコンテンツをよりコンパクトに調整します。
  • やること: 重い描画を行うシーンに isInteractivelyResizing のチェックを入れる。

    • なぜやる価値があるか: iPad のマルチウィンドウや Mac ではユーザーが頻繁にウィンドウサイズをドラッグします。geometry の変化のたびにリソースを再構築するとカクつきますが、手を離した時点で 1 度更新すれば十分です。
    • 始め方: SceneDelegate で windowScene(_:didUpdateEffectiveGeometry:) を実装し、geometry.isInteractivelyResizing == false を判定してから重い処理をトリガーします。ゲームのテクスチャ、動画のデコード、PDF レンダリングなどに適用できます。
  • やること: 手書きの margin 数値を Layout Margins Guide で置き換える。

    • なぜやる価値があるか: 各プラットフォームの標準 margin は異なり、16 や 20 をハードコードすると visionOS や Mac Catalyst でレイアウトが崩れます。Layout Margins Guide は Safe Area とウィンドウコントロールに自動的に追従します。
    • 始め方: view.layoutMarginsGuide を制約のアンカーとして用い、すべてのコンテンツをそこに整列させます。ウィンドウコントロールが絡む場面では、追加で view.layoutGuide(for: .margins(cornerAdaptation: .horizontal)) を使います。

関連セッション

  • Elevate the design of your iPad app — iPad 26 のデザインランゲージ更新。Tab Bar、SplitView、ウィンドウコントロールに関するデザインガイドラインです。
  • Build an AppKit app with the new design — AppKit 側における同根の更新。Mac Catalyst プロジェクトでも参考になります。
  • Create icons with Icon Composer — マルチプラットフォーム対応アイコンを統一的に制作するツール。マルチ Scene アプリのウィンドウアイコンと組み合わせて使えます。
  • Bring Swift Charts to the third dimension — iPad のマルチウィンドウや visionOS における Chart3D の表示。柔軟なレイアウトのもう一つの活用シーンです。

コメント

GitHub Issues · utterances