iOS
UIKit

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

この記事はユニークビジョン株式会社 Advent Calendar 2018の2日目の記事です。


TL;DR



  • UIWindowmakeKeyAndVisible() メソッドはレシーバを前面に表示し、キーボード等のイベントを受け付けるようにするメソッド

  • 新しい UIWindow を生成し、それの makeKeyAndVisible() メソッドを呼ぶことで、表示中のウィンドウの上から新たな画面を表示させることができる

  • それを利用して、表示中の画面に関わらずモーダル表示を行うことができる。強制バージョンアップ等で、全てのユーザにお知らせを表示したい場合等に有用。


調べた動機


ある日 UIWindow が消えた

iOS 開発をしていてスプラッシュ画面が出た直後に画面が真っ暗になる現象に引き起こしてしまいました。その際 Debug View Hierarchy ボタンを押すと以下の通り見事に空っぽになっていました。 UIWindow が消失しており、一つも存在していません。

Screen Shot 2018-12-01 at 15.35.34.png


UIWindow が消えた原因

UIWindowmakeKeyAndVisible() を呼ぶことで画面に見えるようになりますが、その処理を以下のように行ったことが原因でした。


AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

UIWindow().makeKeyAndVisible()
return true
}

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


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 のドキュメントより引用:


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 というのはアプリに同時に1つまで存在できる、タッチ操作に関係しないイベントを受け取るウィンドウの事のようです(タッチ操作イベントはそれが起こったウィンドウに送られます)。level というのはウィンドウの前後関係を示す Z 軸座標を表しています。

詳細はわかりませんが、 makeKeyAndVisible() はあくまでレシーバを key window とし、(level が同じまたはそれ以下のウィンドウの中で)優先表示するもので、どこかから新たに参照を持たれないことになんとなく納得しました。

また、普段アプリ起動直後におまじないのように呼んでいる makeKeyAndVisible() ですが、これは生成した window を表示し、それにイベントを受け取ってもらうために呼んでいたことがわかりました。


新たな UIWindow を生成し、表示中のウィンドウの上から新たなウィンドウの表示を行う

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

extension UIApplication {

static func topViewController() -> UIViewController? {
// UIApplication.keyWindow?.rootViewController から階層をたどっていき最前面の UIViewController を返す
}
}

これだと rootViewController が存在しない場合や、 viewController が childViewControllers を複数保持していた場合、さらにそこに modal 表示された UINavigationController がだったら… と、様々なパターンを全て網羅しなければなりません。実装時点で動いたとしても、今後アプリが改修されていく中で、アプリの画面構造の全てに対応できるロジックを保ち続けられるかには自信が持てません。

このような場合に、新たな UIWindow を生成し、 makeKeyAndVisible() を呼ぶことで現在のウィンドウの上から新たなウィンドウの表示を行う事ができます。少し調べると、すでにそのようなことを実現している方がいらっしゃいました。

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

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


実装してみた    

起動直後にグローバルにモーダル画面を表示し、その後閉じる簡単なサンプルを実装してみました。

globalmodaltest.gif

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 を設定し、 UIWindowrootViewController それぞれ透明にする必要があります。

*2. グローバルなモーダルを閉じた際、以前に表示していたウィンドウを再び key window とするため、使用したウィンドウの isHidden プロパティを true にする必要があります。


まとめ

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

ユニークビジョン株式会社 Advent Calendar 2018 の2日目の記事でした。明日は FuJino の番です。