SwiftUI App Life Cycle で View が属している keyWindow (UIWindow) を取得する
課題
SwiftUI でアプリを書いる場合であっても、何らかの理由で View
が属している UIWindow
を取得したくなることがたまにあるかと思います。
Scene
の概念が増えたことによって、従来の UIApplication.shared.keyWindow
で取得する方法については iOS 13 で deprecated になっており、代わりにUIApplication.shared.windows.first(where: \.isKeyWindow)
のように取得するパターンがあるかと思いますが、こちらも iOS 15 で deprecated となっています。また、自身が所属している Scene
の keyWindow
を正しく判断することができません。
では、今後はどの様に keyWindow を取得すれば良いのでしょうか。
この記事では SwiftUI App Life Cycle を利用した場合の解決案を記載しますが、この記事の結論としては、結局のところ UISceneDelegate を利用する方法となるので、従来型の UIKit App Delegate の Life Cycle と同じような実装を行うことになりました。
実装例
というわけで、早速実装例です。今回の実装の全体像は以下のようになります。
ポイントとしては、
-
UIApplicationDelegateAdapter
を設定して自作のAppDelegate
を利用する -
AppDelegate
で自作のSceneDelegate
を利用するように設定する -
SceneDelegate
内でUIWindowScene
からkeyWindow
(UIWindow
) を取り出す -
SceneDelegate
をObservableObject
に適合させる
というところになります。
@main
struct GetWindowDemoApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
configuration.delegateClass = SceneDelegate.self
return configuration
}
}
final class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
window = (scene as? UIWindowScene)?.keyWindow
}
}
UIApplicationDelegateAdapter
を設定して自作の AppDelegate
を利用する
こちらは主に自作の SceneDelegate
を利用するために必要です。特に難しいことはないですが、公式のドキュメントを参考に実装しています。
import SwiftUI
@main
struct GetWindowDemoApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {}
AppDelegate
で自作の SceneDelegate
を利用するように設定する
こちらも公式のドキュメント(Scene Delegates のセクション)を参考に、自作の SceneDelegate を利用するように設定します。
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
configuration.delegateClass = SceneDelegate.self
return configuration
}
}
final class SceneDelegate: NSObject, UIWindowSceneDelegate {}
SceneDelegate
内で UIWindowScene
から keyWindow
(UIWindow
) を取り出す
UIWindowSceneDelegate
に準拠した SceneDelegate
クラスを作成して scene(_:willConnectTo:options:)
を実装します。そしてそのメソッドの第一引数から UIWindowScene
を取り出し、keyWindow
を SceneDelegate
の window
プロパティに保持しておきます。
(ちなみにこの window
プロパティは、UIWindowSceneDelegate
の実装任意なプロパティになっています)
UIKit App Delegate のパターンでは、このメソッドで keyWindow
を作ったり、UIWindow
に rootViewController
を設定したりすると思うのですが、SwiftUI App Life Cycle の場合、既に keyWindow
や rootViewController
が設定済みの UIWindowScene
を取得することができました。
final class SceneDelegate: NSObject, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
window = (scene as? UIWindowScene)?.keyWindow
// iOS 14 以前をサポートする場合は以下のようにすると良さそう
// window = (scene as? UIWindowScene)?.windows.first(where: \.isKeyWindow)
}
}
SceneDelegate
を ObservableObject
に適合させる
ドキュメントに記載の通り、AppDelegate
や SceneDelegate
をObservableObject
に適合させておくと、自動的に SwiftUI の Environment に設定してくれるため、@EnvironmentObject var sceneDelegate: SceneDelegate
などとしてどの View からでも SceneDelegate
を取り出すことができるようになります。これによって、どの View からでも自身が所属する Scene
の keyWindow
に SceneDelegate
を通じてアクセスできるようになります。
final class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
// 省略
}
As with the app delegate, if you make your scene delegate an observable object, SwiftUI automatically puts it in the Environment, from where you can access it with the EnvironmentObject property wrapper, and create bindings to its published properties.
実装の基礎部分としては以上になります。
利用してみる
以下のようなコードを書いてみて、iPad で Window を二つ開き、それぞれの正しい window を取得できているかを確認しました
struct ContentView: View {
@State var isPresented = false
var body: some View {
VStack {
Button("sheet") {
isPresented = true
}
}
.sheet(isPresented: $isPresented) {
SheetView()
}
}
}
struct SheetView: View {
// こんな感じでとれる
@EnvironmentObject var sceneDelegate: SceneDelegate
var body: some View {
Button("dismiss") {
sceneDelegate.window?.rootViewController?.dismiss(animated: true)
// 以下のやり方は意図通り動くものの deprecated
//UIApplication.shared.keyWindow?.rootViewController?.dismiss(animated: true)
// 以下のやり方だと、自身が所属していない Scene の keyWindow を触ってしまう場合がある
// また、windows が deprecated になっている
// UIApplication.shared.windows.first(where: \.isKeyWindow)?.rootViewController?.dismiss(animated: true)
// 以下のやり方だと、自身が所属していない Scene の keyWindow を触ってしまう場合がある
// UIApplication.shared.connectedScenes
// .compactMap { $0 as? UIWindowScene }
// .flatMap(\.windows)
// .first(where: \.isKeyWindow)?.rootViewController?.dismiss(animated: true)
}
}
}
動作例
意図通りの例 | 意図しない例 |
---|---|
よく見る解答例
他のやり方として、UIApplication.shared.connectedScene
から UIWindowScene
を取り出すやり方が見つかりましたが、そのやり方だと View が所属している Scene をどうやって判断するかが課題になりそうです。
Discussion