SwiftUI App Life Cycle で View が属している keyWindow (UIWindow) を取得する

21391 ワード

課題

SwiftUI でアプリを書いる場合であっても、何らかの理由で View が属している UIWindow を取得したくなることがたまにあるかと思います。

Scene の概念が増えたことによって、従来の UIApplication.shared.keyWindow で取得する方法については iOS 13 で deprecated になっており、代わりにUIApplication.shared.windows.first(where: \.isKeyWindow) のように取得するパターンがあるかと思いますが、こちらも iOS 15 で deprecated となっています。また、自身が所属している ScenekeyWindow を正しく判断することができません。

では、今後はどの様に keyWindow を取得すれば良いのでしょうか。

この記事では SwiftUI App Life Cycle を利用した場合の解決案を記載しますが、この記事の結論としては、結局のところ UISceneDelegate を利用する方法となるので、従来型の UIKit App Delegate の Life Cycle と同じような実装を行うことになりました。

実装例

というわけで、早速実装例です。今回の実装の全体像は以下のようになります。

ポイントとしては、

  • UIApplicationDelegateAdapter を設定して自作の AppDelegate を利用する
  • AppDelegate で自作の SceneDelegate を利用するように設定する
  • SceneDelegate 内で UIWindowScene から keyWindow (UIWindow) を取り出す
  • SceneDelegateObservableObject に適合させる

というところになります。

@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 を取り出し、keyWindowSceneDelegatewindow プロパティに保持しておきます。

(ちなみにこの window プロパティは、UIWindowSceneDelegate実装任意なプロパティになっています)

UIKit App Delegate のパターンでは、このメソッドで keyWindow を作ったり、UIWindowrootViewController を設定したりすると思うのですが、SwiftUI App Life Cycle の場合、既に keyWindowrootViewController が設定済みの 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)
    }
}

SceneDelegateObservableObject に適合させる

ドキュメントに記載の通りAppDelegateSceneDelegateObservableObject に適合させておくと、自動的に SwiftUI の Environment に設定してくれるため、@EnvironmentObject var sceneDelegate: SceneDelegate などとしてどの View からでも SceneDelegate を取り出すことができるようになります。これによって、どの View からでも自身が所属する ScenekeyWindowSceneDelegate を通じてアクセスできるようになります。

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 をどうやって判断するかが課題になりそうです。