
-
UIKitアプリの柔軟性の向上
iPhone、iPad、Mac、Apple Vision Proでシーンとコンテナビューのコントローラを使用することで、UIKitアプリの柔軟性を高める方法を確認します。アプリ中心型からシーンベースのライフサイクルへの移行によって、ウインドウのサイズ変更やマルチタスク処理の向上など、アプリの可能性をフルに引き出せるようになります。インタラクティブな列サイズの変更やインスペクタ列の強力なサポートなど、UISplitViewControllerの機能強化について紹介します。また、新しいレイアウトのAPIを採用することで、ビューやコントロールの適応性を高める方法も説明します。
関連する章
リソース
関連ビデオ
WWDC25
WWDC24
-
このビデオを検索
こんにちは 「Make your UIKit app more flexible」へようこそ UIKitチームのエンジニアの Alexander MacLeodです 柔軟なアプリは 画面サイズやプラットフォームを問わず 優れた体験を届けることができます サイズに関係なく 直感的に使える 慣れ親しんだUIを実現できるのです この動画では アプリに柔軟性を 確保するための ベストプラクティスを紹介していきます 最初に話をするのは シーンという概念の基本についてです シーンがどのような点で柔軟なアプリの 基礎になるかも 併せて説明します 次が コンテナ ビューコントローラについてです UISplitViewControllerや UITabBarControllerなどが アプリにどのように柔軟性を もたらしてくれるかを紹介します 最後が アダプティブで柔軟なUIを 開発する上で役立つ APIについて説明します では シーンの話を始めましょう シーンとは アプリのUIのインスタンスで アプリのビューコントローラと ビューが含まれています シーンでは 外部データを処理するための フックを利用できます 例えば アプリの特定のセクションに ディープリンクするURLなどです
各シーンでは UIの状態を 個別に保存して復元できます 現在の状態を要求するタイミングは 状態をディスクに記憶させる前に シーン側で決定します シーンが再接続されたときには UIの以前の状態をクエリで取得できます そのため シーンを以前と まったく同じ状態に復元できるのです また シーンからは アプリの表示に関する コンテキストも得られます 例えば 画面の詳細や ウィンドウの サイズや位置に関する情報などです シーンは複数設けることができ それぞれが 独自のライフサイクルと状態を保持します また 各種のシーンを別個に管理できるよう 様々なシーンタイプが用意されています 例えば メッセージングアプリには 新規メッセージの送信専用の 作成シーンを用意できます iOS 26では 1つのアプリでSwiftUIとUIKitの シーンタイプを併用できるようになりました 詳しくは「What's New in UIKit」を ご覧ください このシーンの移植性こそ 柔軟なアプリを実現するための基盤です
柔軟性の確保には シーンが欠かせないため UISceneのライフサイクルの導入は まもなく必須になる予定です 具体的には iOS 26以降の メジャーリリースで 開発に 最新のSDKを使用する場合には UISceneのライフサイクルが必須になります
複数シーンのサポートは 推奨されていますが 必須になるのは ライフサイクルの導入のみです UISceneのライフサイクルの導入方法の 詳細については テクニカルノート 「UIKitのシーンベースの ライフサイクルへの移行」をお読みください さて シーンはとても重要なので ここからは実例を1つお見せします こちらは私が開発したアプリで 各種のタスクにかかった時間を追跡できます AirPlayを使って 現在のタスクを Apple TVに表示する機能を備えています このコードではアプリデリゲートが 接続中のセッションの シーン設定の決定を担っています そして このデリゲートのconfigurationFor ConnecingSceneSessionメソッドで シーンセッションの役割を確認します 役割がインタラクティブでない 外部ディスプレイの場合には 専用のシーン設定を返します それ以外の場合には メインの シーン設定が返されます それぞれの設定は アプリの Info.plistファイルで定義されています
UISceneDelegateは 個々のシーンの ライフサイクルを管理するものです sceneWillConnectToSessionでは まずウィンドウを作成し それを接続中のシーンに関連付けます なお シーンの設定でストーリーボードが 指定されている場合には ウィンドウが自動で作成されます
ウィンドウの ルートビューコントローラを指定し そこにタイマーモデルなど シーン固有のデータを与えます 私のアプリでは シーンが バックグラウンドに移行した場合に タイマーを一時停止することが重要です そのため 私はメソッド sceneDidEnterBackgroundを実装し そこでタイマーを一時停止させています 状態の復元の処理では 接続中のシーンのUIの状態が 以前とまったく同じになるようにします このシーンデリゲートには 状態の復元アクティビティがあり 選択内容やナビゲーションパスなど UIの各種状態を含めることができます システムは UIのこの状態を保持して シーンのインスタンスに関連付けます 後でシーンが再接続されると restoreInteractionStateWith userActivityメソッド内で 状態の 復元アクティビティが使えるようになります 私のアプリでは タイマーモデルに ユーザーアクティビティの 情報を渡すことにより 接続中のシーンのUIの状態が 以前とまったく同じになるようにしています
UISceneのライフサイクルを導入した結果 柔軟なアプリのための 強力な基盤ができました ここからは コンテナ ビューコントローラについてです これが柔軟なアプリの構築に どれほど 不可欠なものであるかを説明します コンテナビューコントローラは 子ビューコントローラのレイアウトを 管理する役割を担っています UIKitには 柔軟な設計の コンテナビューコントローラが 多数用意されています 最初に説明するのは UISplitViewControllerです UISplitViewControllerは コンテンツの 隣接する列の表示を管理するもので 情報の階層構造をシームレスに 移動できるようにするために役立ちます 水平方向のスペースが限られている場合には スペースに応じて列が折りたたまれ ナビゲーションスタックにまとめられます UISplitViewControllerには 多くの新機能が追加されていますが 最初に取り上げるのは 列のインタラクティブなサイズ変更です 列の境界をドラッグして 列のサイズを変更できるようになりました 境界にカーソルを合わせると ポインタの形状が変化して どの方向にサイズを 変更できるかが示されます UISplitViewControllerには 各列の最小幅と 最大幅のほか 最適な幅の デフォルト値が用意されています 列によっては デフォルトよりも広い幅で コンテンツを表示した方がよいこともあれば デフォルトの何割かの幅で 十分なこともあります そこで各列の最小幅や最大幅 最適な幅は ビューのプロパティを調整して カスタマイズできるようになっています 列幅をカスタマイズするときは 表示可能な列の数が 制限されないように注意してください アプリの柔軟性が低下してしまいます また ビューの列が 折りたたまれているかどうかに応じて UIの適応が必要になることがあります 例えば メールアプリでは 列が 折りたたまれていると 右矢印が表示され それを選択すると 追加のコンテンツが 表示されることがわかります 新しい特性である splitView ControllerLayoutEnvironmentは 祖先の分割ビューコントローラが 折りたたまれているかどうかを示します この例では この特性をクエリで取得して ビューが折りたたまれている場合に この矢印インジケータを追加しています また インスペクタ列の サポートも追加されました インスペクタは 分割ビュー コントローラー内の列の1つで 選択したコンテンツの 詳細情報が表示されます このプレビューでは インスペクタを使って 写真の列の横に 写真のメタデータを表示しています 分割ビューコントローラが 展開されているときは インスペクタ列は 画面端に 他の列に隣接して配置されます
分割ビューコントローラが折りたたまれると 表示が自動で調整され インスペクタ列がシートとして表示されます
分割ビューコントローラに インスペクタを組み込むには インスペクタ列用の ビューコントローラを指定します 分割ビューコントローラを表示した時点では インスペクタ列は非表示になっています そこでshowを呼び出して インスペクタ列を表示します UISplitViewControllerは 柔軟な設計なので 画面サイズに関係なく アプリで 最高のナビゲーション体験を実現できます 自由に使えるもう1つのコンテナが UITabBarControllerです UITabBarControllerは 相互排他的なペインを 同じ領域に表示するためのものです タブバーでは 各ペインの 現在の状態を保持したまま タブを素早く切り替えて表示できます さらに タブバーの外観は プラットフォームに応じて変化します
iPhoneなら タブバーは シーンの下部に配置されます Macでは ツールバーに配置されますが サイドバーとして表示されることもあります
Apple Vision Proの場合には タブバーはシーン左端の オーナメントに表示されます iPadの場合は シーンの最上部の ナビゲーションコントロールの隣にあります タブバーをサイドバーに変化させて さまざまなコンテンツに素早く アクセスできるようにすることも可能です タブグループは サイドバーに 追加の移動先を表示するものです 例えば iPadのミュージックアプリなら というタブグループに やがあります
サイドバーが使用できない場合 は移動先のタブになります
UITabBarControllerには この適応動作を シームレスに管理するためのAPIがあります まずはタブグループに 管理ナビゲーション コントローラを設定します タブグループのリーフタブが 選択されると そのビューコントローラが その祖先グループの ビューコントローラと一緒に ナビゲーションスタックにプッシュされます このスタックにプッシュされる ビューコントローラをカスタマイズするには UITabBarControllerの デリゲートメソッドを実装します displayedViewControllersFor tabです この例では タブが 選択できない場合 メソッドから空の配列が返され タブのビューコントローラが スタックから省略されます
UITabBarControllerが タブバーやサイドバーの表示に どんな柔軟性をもたらすかの詳細については WWDC24の「Elevate your tab and sidebar experience in iPadOS」をご覧ください アプリの柔軟性を確保するには UISplitViewControllerや UITabBarControllerなどのコンテナ ビューコントローラを使用するのが一番です どのコンテナも 幅広いサイズに 対応できる設計ではありますが アプリでは 主要な機能を維持する上で 最小サイズが必要になることもあります そのような場合には UISceneSizeRestrictions APIを使用して シーンのコンテンツに必要な 最小サイズを指定します 最小サイズを指定する最適なタイミングは シーンを接続しようとするときです この例では 最小幅として 500ポイントを指定しています 柔軟なアプリを実現するには そのUIが 多様なデバイスに適応できる必要があります そこで ここからはアダプティブUIの 構築を支援するAPIについて説明します UIをアダプティブにするために不可欠なのが コンテンツを常に セーフエリア内にとどめることです セーフエリアは ビュー内の領域の1つで インタラクティブなコンテンツや 重要なコンテンツに適した場所です この外部に配置されたコンテンツは ナビゲーションバーやツールバーなどの下に 隠れて見えなくなる可能性が高まります
また ステータスバーなどのシステムUIや ダイナミックアイランドなどの デバイス機能でも コンテンツが 隠れてしまうことがあります
サイドバーは 分割ビューコントローラの 隣接する列に 非対称のセーフエリアを 追加するものです 背景は サイドバーの下にあり セーフエリアの外側にも自由に展開できます
メッセージのトランスクリプトなどの コンテンツは 常に表示されるよう セーフエリア内に配置されます メッセージの吹き出しは セーフエリア内の端付近に表示されています レイアウト余白を使用しているので サイドバーと一定の間隔が確保され 視覚的に明確に分離されています
各ビューには コンテンツに標準余白を 適用するためのレイアウトガイドがあります レイアウト余白は デフォルトでは セーフエリアの内部に適用されます この例では コンテナビューの内部に コンテンツを配置する レイアウトガイドを要求しています その上で このガイドを使用して コンテンツビューに制約を設定しています
iPadOS 26では macOSと同じように ウィンドウを閉じたり 最小化したり フルスクリーンにしたりできる コントロールが シーンに導入されています このウィンドウコントロールは シーン内の コンテンツの横に表示されます
シーンには コンテンツに合ったウィンドウ コントロールのスタイルを指定できます それを行うには シーンに UIWindowSceneDelegateのメソッド preferredWindowingControlStyleを 実装します
UINavigationBarなどのシステム コンポーネントの適応処理は 自動的にコントロール周囲の サブビューが調整されて完了します またUIは スタイルに関係なく ウィンドウ コントロールに適応させる必要があります
UIが隠れないようにするためには ウィンドウコントロールを考慮した レイアウトガイドを使用します この例では 水平方向の角の適応を伴う レイアウト余白ガイドを採用しています このレイアウトガイドは シーン上部の バーのようなコンテンツに最適で ウィンドウコントロールの 後端からのインセットになります 続いて このガイドを使用して コンテンツビューに制約を設定します アダプティブUIでは インターフェイスの 向きに冗長性を確保する必要があります シーンのサイズ変更でも デバイスの回転や ウィンドウレイアウトの変更でも 最終的には必ず シーンのサイズ変更が発生します アプリのカテゴリによっては 向きの変更を 一時的にロックした方がよいこともあります 例えば レースゲームでハンドル操作に デバイスを回転させることが 想定されている場合には 向きの変更をロックする必要があります
ビューコントローラが表示されているときは インターフェイスの向きをロックします それには コントローラのサブクラスで prefersInterfaceOrientationLockedを オーバーライドします この設定が変更されるたびに setNeedsUpdateOfPrefersInterface OrientationLockedを呼び出します
向きの変更のロック状況を確認するには UIWindowSceneDelegateのメソッド didUpdateEffectiveGeometryを実装し isInterfaceOrientationLockedの 値が変更されたかどうかを比較します 柔軟なアプリは サイズ変更に 迅速に対応できなければなりません しかし アプリのUI要素によっては 描画の計算コストが大きいこともあります
例えばゲームでは シーンのサイズが 変わると 大量のアセットのサイズ変更が 必要になることが多々あります しかし サイズ変更操作の間にアセットを 逐一レンダリングする必要はありません この例では isInteractivelyResizingを クエリして アセットの更新を サイズ変更操作の完了後の 新しいサイズへの更新だけにしています
ユーザーがデバイスを好きなように 使えることこそ 柔軟なアプリの魅力です さまざまなサイズで 優れた体験を届けられるので 向きやレイアウトを問わず 使えるアプリになるのです Info.plistのUIRequiresFullscreenキーは iOS 9から導入された互換モードで シーンのサイズ変更を防ぎます UIRequiresFullscreenは非推奨であり 今後のリリースでは無視される予定です アダプティブUIのアプリでは このキーは 必要ないため 削除する必要があります
また 特に新しいハードウェアを対象とした 別の互換モードもあります 以前は 画面サイズの異なる 新しいハードウェアがリリースされると アプリのUIのスケーリングや レターボックスが必要になっていました このスケーリングは アプリを新しいSDKでビルドして 再提出するまで維持されます iOS 26 SDKを使用してビルドした アプリを提出すると その後はアプリのUIのスケーリングや レターボックスの追加が行われなくなります
アプリの柔軟性を確保するための ベストプラクティスは以上です 次のステップは まず シーンのライフサイクルの導入です これは 柔軟なアプリの 強力な基盤になります 次が UIのコンポーネント管理に向けた コンテナビューコントローラの導入です そして最後が レイアウトガイドなどの APIによるアダプティブUIの構築です 皆さんのアプリの柔軟性が 高まるのを楽しみにしています ありがとうございました
-
-
3:02 - Specify the scene configuration
// Specify the scene configuration @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) } } }
-
3:30 - Configure the UI
// Configure the UI 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 } }
-
3:56 - Handle life cycle events
// Handle life cycle events class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var timerModel = TimerModel() // ... func sceneDidEnterBackground(_ scene: UIScene) { timerModel.pause() } }
-
4:09 - Restore UI state
// Restore UI state class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var timerModel = TimerModel() // ... 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?["selectedTimeFormat"] as? String { timerModel.selectedTimeFormat = selectedTimeFormat } }
-
4:46 - Adapt for the split view controller layout environment
// Adapt for the split view controller layout environment override func updateConfiguration(using state: UICellConfigurationState) { // ... if state.traitCollection.splitViewControllerLayoutEnvironment == .collapsed { accessories = [.disclosureIndicator()] } else { accessories = [] } }
-
6:11 - Customize the minimum, maximum, and preferred column widths
// Customize the minimum, maximum, and preferred column widths let splitViewController = // ... splitViewController.minimumPrimaryColumnWidth = 200.0 splitViewController.maximumPrimaryColumnWidth = 400.0 splitViewController.preferredSupplementaryColumnWidth = 500.0
-
7:37 - Show an inspector column
// Show an inspector column let splitViewController = // ... splitViewController.setViewController(inspectorViewController, for: .inspector) splitViewController.show(.inspector)
-
9:19 - Managing tab groups
// Managing tab groups let group = UITabGroup(title: "Library", ...) group.managingNavigationController = UINavigationController() // ... // MARK: - UITabBarControllerDelegate func tabBarController( _ tabBarController: UITabBarController, displayedViewControllersFor tab: UITab, proposedViewControllers: [UIViewController]) -> [UIViewController] { if tab.identifier == "Library" && !self.allowsSelectingLibraryTab { return [] } else { return proposedViewControllers } }
-
10:25 - Preferred minimum size
// Specify a preferred minimum size class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { let windowScene = scene as! UIWindowScene windowScene.sizeRestrictions?.minimumSize.width = 500.0 } }
-
11:57 - Position content using the layout margins guide
// Position content using the layout margins guide let containerView = // ... let contentView = // ... let contentGuide = containerView.layoutMarginsGuide 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) ])
-
12:34 - Specify the window control style
// Specify the window control style class SceneDelegate: UIResponder, UIWindowSceneDelegate { func preferredWindowingControlStyle( for scene: UIWindowScene) -> UIWindowScene.WindowingControlStyle { return .unified } }
-
13:04 - Respect the window control area
// Respect the window control area let containerView = // ... let contentView = // ... 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) ])
-
13:57 - Request orientation lock
// Request orientation lock class RaceViewController: UIViewController { override var prefersInterfaceOrientationLocked: Bool { return isDriving } // ... var isDriving: Bool = false { didSet { if isDriving != oldValue { setNeedsUpdateOfPrefersInterfaceOrientationLocked() } } } }
-
14:18 - Observe the interface orientation lock
// Observe the interface orientation lock class SceneDelegate: UIResponder, UIWindowSceneDelegate { var game = Game() func windowScene( _ windowScene: UIWindowScene, didUpdateEffectiveGeometry previousGeometry: UIWindowScene.Geometry) { let wasLocked = previousGeometry.isInterfaceOrientationLocked let isLocked = windowScene.effectiveGeometry.isInterfaceOrientationLocked if wasLocked != isLocked { game.pauseIfNeeded(isInterfaceOrientationLocked: isLocked) } } }
-
14:44 - Query whether the scene is resizing
// Query whether the scene is resizing class SceneDelegate: UIResponder, UIWindowSceneDelegate { var gameAssetManager = GameAssetManager() var previousSceneSize = CGSize.zero 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) } } }
-