UIWindow を使用して安全にグローバルなモーダル画面を表示する

この記事は[ユニークビジョン株式会社 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/uniquevision)の2日目の記事です。

## TL;DR
* `UIWindow` の `makeKeyAndVisible()` メソッドはレシーバを前面に表示し、キーボード等のイベントを受け付けるようにするメソッド
* 新しい `UIWindow` を生成し、それの `makeKeyAndVisible()` メソッドを呼ぶことで、表示中のウィンドウの上から新たな画面を表示させることができる
* それを利用して、表示中の画面に関わらずモーダル表示を行うことができる。強制バージョンアップ等で、全てのユーザにお知らせを表示したい場合等に有用。

## 調べた動機

#### ある日 `UIWindow` が消えた
iOS 開発をしていてスプラッシュ画面が出た直後に画面が真っ暗になる現象に引き起こしてしまいました。その際 [Debug View Hierarchy ボタン](https://developer.apple.com/jp/documentation/ToolsLanguages/Conceptual/Xcode_Overview/Debugging/ExaminingtheViewHierarchy.html)を押すと以下の通り見事に空っぽになっていました。 UIWindow が消失しており、一つも存在していません。
<img width="75%" alt="Screen Shot 2018-12-01 at 15.35.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/92385/02a0a0a7-2ac4-cf32-189f-a5338ddfb24a.png">

#### `UIWindow` が消えた原因
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">UIWindow().makeKeyAndVisible() した後そのインスタンスを参照してなかったせいで UIWindow のインスタンスが deallocate されて画面が起動直後に真っ暗となってしまい悩んだ <a href="https://twitter.com/hashtag/swift?src=hash&amp;ref_src=twsrc%5Etfw">#swift</a> <a href="https://twitter.com/hashtag/uiwindow?src=hash&amp;ref_src=twsrc%5Etfw">#uiwindow</a></p>&mdash; Kosuke Ohmura (@kosukeohmura) <a href="https://twitter.com/kosukeohmura/status/1068751423083503617?ref_src=twsrc%5Etfw">2018年12月1日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
`UIWindow` は `makeKeyAndVisible()` を呼ぶことで画面に見えるようになりますが、その処理を以下のように行ったことが原因でした。

```swift:AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UIWindow().makeKeyAndVisible()
    return true
}
```

これだと `makeKeyAndVisible()` を呼んだ後に `UIWindow` のインスタンスへの参照カウントが 0 になることにより開放されてしまいます。よって以下のように修正することで `UIWindow` の開放を避けることができました。新規アプリ作成時から `AppDelegate` が参照を保持するようになっているので、通常はこのようなことは起こりません。

```swift:AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let window = UIWindow()
    self.window = window // 参照を保持しておく
    window.makeKeyAndVisible()
    return true
}
```

ウィンドウは無事現れましたが、`makeKeyAndVisible()` を呼ぶことで何かしらの参照が残っても良いんじゃないだろうか。という疑問が湧いたので、これを機に調べてみることにしました。

## `makeKeyAndVisible()` は何をするメソッドか

[Apple のドキュメント](https://developer.apple.com/documentation/uikit/uiwindow/1621601-makekeyandvisible)より引用:
> Shows the window and makes it the key window.
> This is a convenience method to show the current window and position it in front of all other windows at the same level or lower.

レシーバの `UIWindow` を key window とし、かつ同一の level にあるウィンドウの中で最も前面に表示してくれるメソッドということがわかります。key window というのはアプリに同時に１つまで存在できる、タッチ操作に関係しないイベントを受け取るウィンドウの事のようです（タッチ操作イベントはそれが起こったウィンドウに送られます）。level というのはウィンドウの前後関係を示す Z 軸座標を表しています。
詳細はわかりませんが、 `makeKeyAndVisible()` はあくまでレシーバを key window とし、（level が同じまたはそれ以下のウィンドウの中で）優先表示するもので、どこかから新たに参照を持たれないことになんとなく納得しました。
また、普段アプリ起動直後におまじないのように呼んでいる `makeKeyAndVisible()` ですが、これは生成した window を表示し、それにイベントを受け取ってもらうために呼んでいたことがわかりました。

## 新たな `UIWindow` を生成し、表示中のウィンドウの上から新たなウィンドウの表示を行う
強制バージョンアップの仕組みや、全ユーザ向けに通知したいお知らせなど、現在どんな画面が表示されているかに関わらず、グローバルにモーダル画面を表示したいことがあると思います。そんなとき、自分はこれまではそのような場合に以下のようなメソッドを作成し最前面の `UIViewController` を取得し、それに対して `present(_:animated:completion:)` メソッドを呼んでいました。

```swift
extension UIApplication {
    static func topViewController() -> UIViewController? {
        // UIApplication.keyWindow?.rootViewController から階層をたどっていき最前面の UIViewController を返す
    }
}
```
これだと `rootViewController` が存在しない場合や、 viewController が `childViewControllers` を複数保持していた場合、さらにそこに modal 表示された `UINavigationController` がだったら… と、様々なパターンを全て網羅しなければなりません。実装時点で動いたとしても、今後アプリが改修されていく中で、アプリの画面構造の全てに対応できるロジックを保ち続けられるかには自信が持てません。
このような場合に、新たな `UIWindow` を生成し、 `makeKeyAndVisible()` を呼ぶことで現在のウィンドウの上から新たなウィンドウの表示を行う事ができます。少し調べると、すでにそのようなことを実現している方がいらっしゃいました。

* [【Xcode5】UIWindowでモーダルウィンドウ(オーバーレイ)を作成](https://www.bhnt.co.jp/blog/%E3%83%84%E3%83%BC%E3%83%AB/xcode/%E3%80%90xcode5%E3%80%91uiwindow%E3%81%A7%E3%83%A2%E3%83%BC%E3%83%80%E3%83%AB%E3%82%A6%E3%82%A3%E3%83%B3%E3%83%89%E3%82%A6%E3%82%AA%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%A4%E3%82%92%E4%BD%9C/)
* [[iOS] AlertやModalを閉じるときに上に重ねてインジケーターを表示したい](http://www.kuma-de.com/blog/2017-05-12/7312)

単なるアラート等ではなく、遷移構造ごと分けたという例も見つけました。

* [複数のUIWindowを利用してFiNCアプリの遷移構造を分けた話](https://medium.com/finc-engineering/ios-multiple-uiwindow-f39ffccad510)

自分でも実装してみたいと思います。

## 実装してみた　　　　
起動直後にグローバルにモーダル画面を表示し、その後閉じる簡単なサンプルを実装してみました。
![globalmodaltest.gif](https://qiita-image-store.s3.amazonaws.com/0/92385/4a7c1c26-2c99-4810-9117-c2f6a235c83f.gif)

```swift
class ViewController: UIViewController {
    private let globalModal = GlobalModal()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .gray
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            self.globalModal.present(viewController: ModalViewController(), animated: true, completion: nil)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) {
            self.globalModal.dismiss(animated: true, completion: nil)
        }
    }
}

class ModalViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .blue
    }
}

// グローバルにモーダルを表示／非表示するクラス
class GlobalModal {
    private let window: UIWindow = {
        let window = UIWindow()
        window.backgroundColor = .clear // *1
        return window
    }()

    func present(viewController: UIViewController, animated: Bool, completion: (() -> Void)?) {
        dismiss(animated: animated, completion: nil)
        let rootViewController = UIViewController()
        rootViewController.view.backgroundColor = .clear // *1
        window.rootViewController = rootViewController
        window.makeKeyAndVisible()

        rootViewController.present(viewController, animated: animated, completion: completion)
    }

    func dismiss(animated: Bool, completion: (() -> Void)?) {
        let completion: () -> Void = {
            completion?()
            self.window.isHidden = true // *2
        }
        window.rootViewController?.presentedViewController?.dismiss(animated: animated, completion: completion)
    }
}
```

概ね簡単に実装できましたが、注意する必要があると感じた点は以下です。

*1. グローバルなモーダル画面に使用する `UIWindow` には `rootViewController` を設定し、 `UIWindow` と `rootViewController` それぞれ透明にする必要があります。
*2. グローバルなモーダルを閉じた際、以前に表示していたウィンドウを再び key window とするため、使用したウィンドウの `isHidden` プロパティを `true` にする必要があります。

## まとめ
新しい `UIWindow` を生成し、それの `makeKeyAndVisible()` メソッドを呼ぶことで、現在の key window の上から新たな画面を表示させることができます。これは表示中のウィンドウの状態に関わらず、その上からモーダル表示したいケースや、表示中のウィンドウと切り離した画面遷移をしたい場合に有用です。

[ユニークビジョン株式会社 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/uniquevision) の2日目の記事でした。明日は [FuJino](https://qiita.com/FuJino) の番です。