iOS開発における「ウィンドウ」とは?
デスクトップOSであれば、一つのアプリが複数のウィンドウを同時に展開するマルチウィンドウアプリであることは、ほぼ当たり前ですよね。画面上にいくつものウィンドウを同時に開いて、並べたり切り替えたりしながら画面の広さを活かしたやり方で作業するものです。
これに対して、iOSの世界観は大きく様相が異なります。高精細なRetinaディスプレイが普及したとはいえ、iPhoneの画面はやはりお世辞にも広くはありません。一般的にiOSでは、一つのアプリが画面全体を専有します。限られた画面空間の中で、いかに無駄なく迷いのないユーザーインターフェースを実現するか、ということに多くの開発者が熟慮を重ねています。
一見するとiOSアプリはシングルウィンドウのように思えてしまいますが、実はiOSのウィンドウ(UIWindow
)は随所で使われています。この記事では、UIWindow
の知られざる登場シーンを紹介し、活用方法や注意すべき点について紹介してみます。
通常ほとんど意識されないUIWindow
Xcodeには、いくつかプロジェクトテンプレートが最初から用意されています。
最近ではstoryboardを使用した開発が主流かと思いますが、その典型であるSingle View Applicationテンプレートを使用した時に自動生成される初期コードを眺めると、UIWindow
は以下の場所に現れるのみです。
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
これはMain Interfaceとして設定されたstoryboardがあるためで、この場合は初期画面となるビューコントローラーの生成だけでなく、それを保持するためのウィンドウの生成も自動で行われて上記のwindow
プロパティに設定されます。
Empty Applicationテンプレートを使用すると初期コードがもう少し増えますが
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
// Override point for customization after application launch.
self.window.backgroundColor = [UIColor whiteColor];
[self.window makeKeyAndVisible];
return YES;
}
と、見ての通り「メインスクリーン全体を覆うウィンドウを用意して表示した」だけですね。
実際、iOS開発はざっくり言うと「UIViewController
に画面制御を実装すること」と表現できます。アプリプログラマが主に責任をもつのはウィンドウの中にはめ込まれたビューの制御部分です。ウィンドウの管理はプロジェクトテンプレート任せでほとんど意識する必要もなくアプリを開発することができます。
UIWindow Class Referenceを眺める
アプリ開発ではほとんど意識する必要もないとはいえ、UIWindow Class Referenceはちゃんと用意されています。
パッと見て、UIWindow
はUIView
のサブクラスであり、タッチイベントやフォーカス、座標変換において特別な役割を果たしているのが見てとれます。
Referenceの下の方に行くと、Notificationsのセクションがあり、以下の通知が用意されていることがわかります。
UIWindowDidBecomeVisibleNotification
UIWindowDidBecomeHiddenNotification
UIWindowDidBecomeKeyNotification
UIWindowDidResignKeyNotification
UIKeyboardWillShowNotification
UIKeyboardDidShowNotification
UIKeyboardWillHideNotification
UIKeyboardDidHideNotification
UIKeyboardWillChangeFrameNotification
UIKeyboardDidChangeFrameNotification
ここで、キーボード制御に関する通知がUIWindow
のReferenceに収録されていることになんとなく違和感を感じませんか?
キーウィンドウ(入力を受け付けるウィンドウ)という概念がある以上、キー入力と密接な関係がありそうというのはうなずけるのですが、あえてキーボードそのものについてUIWindow
のドキュメントに掲載しているのはなぜでしょう?
もしかしてキーボードに紐付いた UIWindow
というのが存在する…?
アプリ内で表示されるUIWindowをフックしてみる
この疑惑を検証する方法を考えてみます。
UIWindowDidBecomeVisibleNotification
は、notification objectが「表示されたUIWindow
インスタンス」になると書かれているので、このnotificationを監視するのが良さそうです。Single View ApplicationプロジェクトテンプレートでAppDelegateのコードを少し変更し、NSNotificationCenter
ですべてのウィンドウからのUIWindowDidBecomeVisibleNotification
を監視するコードを追加します。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(windowDidBecomeVisible:)
name:UIWindowDidBecomeVisibleNotification
object:nil];
return YES;
}
-(void)windowDidBecomeVisible:(NSNotification*)noti
{
UIWindow *window = noti.object;
NSLog(@"window = %@, windowLevel = %@", window, @(window.windowLevel));
NSLog(@"app's windows = %@", [UIApplication sharedApplication].windows);
}
そして、ルートビューコントローラーを生成するstoryboardは、図のようにUITextFieldを置きます。
シミュレーターでアプリを実行し、さっきのテキストフィールドをタップしてキーボードを表示してみましょう。するとコンソールログには…
2014-04-28 04:44:39.084 Window[26436:60b] window = <UIWindow: 0x8f138f0; frame = (0 0; 320 480); autoresize = W+H; gestureRecognizers = <NSArray: 0x8f128c0>; layer = <UIWindowLayer: 0x8f120a0>>, windowLevel = 0
2014-04-28 04:44:39.086 Window[26436:60b] app's windows = (
"<UIWindow: 0x8f138f0; frame = (0 0; 320 480); autoresize = W+H; gestureRecognizers = <NSArray: 0x8f128c0>; layer = <UIWindowLayer: 0x8f120a0>>"
)
2014-04-28 04:44:58.182 Window[26436:60b] window = <UITextEffectsWindow: 0x8e76340; frame = (0 0; 320 480); opaque = NO; gestureRecognizers = <NSArray: 0x8e768c0>; layer = <UIWindowLayer: 0x8e764c0>>, windowLevel = 10
2014-04-28 04:44:58.182 Window[26436:60b] app's windows = (
"<UIWindow: 0x8f138f0; frame = (0 0; 320 480); autoresize = W+H; gestureRecognizers = <NSArray: 0x8f128c0>; layer = <UIWindowLayer: 0x8f120a0>>",
"<UITextEffectsWindow: 0x8e76340; frame = (0 0; 320 480); opaque = NO; gestureRecognizers = <NSArray: 0x8e768c0>; layer = <UIWindowLayer: 0x8e764c0>>"
)
UITextEffectsWindow
という見慣れない名前のウィンドウが検出されています。また、UIApplication
のwindows
プロパティから得られた情報として、アプリのメインのウィンドウよりも高いwindowLevel
で出現していることもわかります。
アプリ上に表示されるキーボードは実は別のウィンドウをかぶせる形で表示されていたわけです。キーボードの出現がアプリのビュー階層に影響を与えないのはこのような構成のおかげなのでした。
同じ要領で、たとえばUIAlertViewを表示した場合にも
2014-05-01 12:26:25.764 Window[4686:60b] window = <_UIModalItemHostingWindow: 0x8e66400; frame = (0 0; 320 480); alpha = 0; gestureRecognizers = <NSArray: 0x8e55b60>; layer = <UIWindowLayer: 0x8e5aac0>>, windowLevel = 0
2014-05-01 12:26:25.764 Window[4686:60b] app's windows = (
"<UIWindow: 0x8d6a5e0; frame = (0 0; 320 480); autoresize = W+H; gestureRecognizers = <NSArray: 0x8d695b0>; layer = <UIWindowLayer: 0x8d69f00>>"
)
のようなコンソールログが取れて、_UIModalItemHostingWindow
という非公開のウィンドウが実は乗り上げていることがわかります。
ただ、windows
プロパティのログの中に_UIModalItemHostingWindow
が入っていないという違いをここで指摘しておきましょう。UIApplication Class Referenceのwindows
プロパティの解説によると、このプロパティはシステム管理下にあるウィンドウを含まないということなので、UIAlertView
はOS側に主権があるということを意味していると考えられます。たしかにUIAlertView
はOS全体で統一の表現ということでUIAppearanceによるカラー変更も効きませんしね…。
おそらくほかにも、オーバーレイ表示されるUIに付随した隠しウィンドウ実装はあるものと推察されますが、ここではこれ以上深入りしないでおきます。興味のある方は調べてみてください。
なお、UIWindowDidBecomeVisibleNotification
でつまみ上げた非公開のウィンドウにサブビューを追加したりといった改変なども技術的には可能ですが、あくまでも非公開のクラスですので、やり方によってはApp Store Reviewでrejectされてしまうリスクが伴うことを念のため付記しておきます。
UIWindowを使って自前のダイアログを表示してみる
実際に UIWindow
を使用したカスタムダイアログの実装方法をご紹介します。ここでは紙面の都合もあるのでサンプルコードを提示しておきます。
- https://github.com/sintario/STOWindowSample の SampleWindowプロジェクト
要点だけかいつまんで説明すると
- 図のような、メインのウィンドウを用意してみました。「レビューをお願いします!」ボタンをタップしたらカスタムダイアログでレビューのお願いを表示したいとします。
- storyboardでダイアログを実装します。このとき、背景となるビューの
backgroundColor
をclearColor
に設定しておきます。
- コードで
UIWindow
を生成します。ここで、windowLevel
としてメインウィンドウよりも高いウィンドウレベルを設定し、backgroundColor
としてアルファを1未満にした色を設定します。 - ウィンドウの
rootViewController
としてstoryboardのinitialViewControllerを差し込んでからmakeKeyAndVisible
します。すると、メインのウィンドウを背後に透かして見せながらオーバーレイする形でカスタムダイアログが表示できます。
#import <objc/runtime.h>
static const char kAssocKey_Window;
@implementation CRSRatingViewController
+(void)show
{
UIWindow *window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
window.alpha = 0.;
window.transform = CGAffineTransformMakeScale(1.1, 1.1);
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Dialog" bundle:nil];
window.rootViewController = [storyboard instantiateInitialViewController];
window.backgroundColor = [UIColor colorWithWhite:0 alpha:.6];
window.windowLevel = UIWindowLevelNormal + 5; // テキトーにちょっと高い
[window makeKeyAndVisible];
// ウィンドウのオーナーとしてアプリ自身に括りつけとく
objc_setAssociatedObject([UIApplication sharedApplication], &kAssocKey_Window, window, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[UIView transitionWithView:window duration:.2 options:UIViewAnimationOptionTransitionCrossDissolve|UIViewAnimationOptionCurveEaseInOut animations:^{
window.alpha = 1.;
window.transform = CGAffineTransformIdentity;
} completion:^(BOOL finished) {
}];
}
- カスタムダイアログを消すには、先ほどコードで生成したウィンドウの所有権を破棄してメインのウィンドウに対して
makeKeyAndVisible
します。UIWindow
にはresignKeyWindow
というメソッドがあるのでついつい明示的にコールしたくなるかもしれないですが、resignKeyWindow
はキーウィンドウの切り替わりに際して、OS側で自動的に呼び出すものなので、アプリプログラマ側で呼び出してはいけません。
-(void)doClose
{
UIWindow *window = objc_getAssociatedObject([UIApplication sharedApplication], &kAssocKey_Window);
[UIView transitionWithView:window
duration:.3
options:UIViewAnimationOptionTransitionCrossDissolve|UIViewAnimationOptionCurveEaseInOut
animations:^{
UIView *view = window.rootViewController.view;
for (UIView *v in view.subviews) {
v.transform = CGAffineTransformMakeScale(.8, .8);
}
window.alpha = 0;
}
completion:^(BOOL finished) {
[window.rootViewController.view removeFromSuperview];
window.rootViewController = nil;
// 上乗せしたウィンドウを破棄
objc_setAssociatedObject([UIApplication sharedApplication], &kAssocKey_Window, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// メインウィンドウをキーウィンドウにする
UIWindow *nextWindow = [[UIApplication sharedApplication].delegate window];
[nextWindow makeKeyAndVisible];
}];
}
今回はシンプルに実現するために、同じカスタムダイアログが複数重ねて表示されるようなケースは考慮から外しましたが、もちろん実装を工夫すればそういうケースでも問題ない動作にすることは可能です。
UIWindow使用上の注意
UIWindowを複数表示する場合に注意すべきことを少しだけ。
Ownershipに注意
通常、UIView
は superview
が参照カウントの意味でのOwnerとなって保持されるわけですが、UIWindown
のsuperview
はウィンドウが表示されている時でも通常 nil
です。ウィンドウのインスタンスを生成して、アニメーションをかけながらmakeKeyAndVisible
したとしても、誰もオーナーになっていないウィンドウは即刻リソース解放されてしまい表示されません。
UIWindowを表示している間、アプリ内のどこかで参照カウントを保持してもらう必要があります。UIApplication
や UIApplicationDelegate
など、アプリ内で唯一性があってアプリとライフサイクルを共にするオブジェクトにウィンドウを所有させるのが最も単純には思いつきます。もちろんアプリによって都合の良い設計は変わってくるでしょう。
windowLevelに注意
UIWindow
のwindowLevel
プロパティはウィンドウのz座標を定めるものです(値が大きいほど視線方向手前)。UIKitでは以下の3つのレベル階層が規定されています。
const UIWindowLevel UIWindowLevelNormal;
const UIWindowLevel UIWindowLevelAlert;
const UIWindowLevel UIWindowLevelStatusBar;
typedef CGFloat UIWindowLevel;
具体的な値はNSLogとかで吐き出してみればわかりますが、iOS7ではNormal = 0, StatusBar = 1000, Alert = 2000となっているようです(具体的な値を使ったコーディングはするべきではないです、念のため)。
ただ、定数名から UIAlertView
が乗っているウィンドウは UIWindowLevelAlert
なんだろうと想像してしまうわけですが、実はそうでもなく、UIAlertView
の実効的なウィンドウレベルはもっと低かったりするので注意が必要です。
実際、AppDelegateの持っているメインウィンドウにUIWindowLevelAlert
を設定してメインウィンドウからUIAlertView
を表示するコードを書いてみるとアラートが画面に登場せず、メインウィンドウの背面にアラートが表示されてしまいます。
こうなってしまうと、アラートで何らかの選択をユーザーに行わせることがアプリのストーリー上必須だったりする場合にはアプリが詰んでしまいますので、windowLevel
で画面階層を管理する場合は UIKit が暗黙に用意しているウィンドウ群とのレベルの整合性に問題がないかよく検討して実装・QA確認を行っておくべきです。
最後に
本文中で紹介したようなダイアログに限らず、例えばWebビューを使ってキャンペーンページを UIWindow
でメインのUIに被せて表示したりとかいったことも思いつきます。メインのUI構成や画面遷移構成に混ぜたくないもの・分離のよいストーリーを表示する方法としてマルチウィンドウを使用することができます。
特にウィンドウ同士を排他ではなくオーバーレイで表示できるので、iOS7の重層的なユーザー誘導を重視するUIガイドラインにも合致するものです。場面を転換する手法として、モーダル表示やUINavigationController
によるナビゲーション遷移のほかにUIWindow
を使う方法もあるということを覚えておくとどこかで使用する場面もあるかもしれないですね。
また、本稿を書きながらの雑感として、Androidアプリ開発との対応関係があるように思いました。AndroidのUIパーツとの対比で言うと、UIWindow
の立ち位置は Activity
に相当するように思えます。どちらも、ひとつながりのある程度独立性のある機能・ストーリーをまとめる単位として扱うのに適しています。
AndroidではActivity
をスタック状に積み上げながら操作を進めていく実装が取られますが、これに相当する動作を実現するのに UIWindow
を使うことができるでしょう。Activity
の中での画面遷移を構成するFragment
はこの対比で言うとUIViewController
に相当するように思えます。
AndroidからiOSへ、あるいはその逆にiOSからAndroidへ、アプリを移植したり並行開発したりする際に、基本的なUIパーツについて対応関係が見いだせると捗ることもあるでしょう。どのプラットフォームでも、部品は違えども基本的な設計が似通った形にできるとおもしろいですね。
CodeIQコード銀行にあなたのコードを預けてみませんか?
- CodeIQコード銀行ではあなたのコードを財産と考えます。
- お預かりいただいたコードは、CodeIQコード銀行がしっかり評価し、フィードバックいたします。
- 当コード銀行にお預けいただいたコードは、企業がみてスカウトをかける可能性があります。
- 転職したい方や将来転職することを考えている方で、今の自分のスキルレベルを知りたい方はぜひ挑戦してみてください。
- 企業からスカウトがきたら困る人は挑戦しないでください。
興味を持った方はこちらからチャレンジを!
株式会社ディー・エヌ・エーで働くスマホクライアントアプリエンジニア。前職ではMac OS向けの某日本語変換ソフトの開発に従事していたが、スマホがいじりたくなって今に至る。
現在はチラシアプリ「チラシル」の開発を担当。Twitter: @sintario