CodeIQ MAGAZINECodeIQ MAGAZINE

iOS開発におけるウィンドウ「UIWindow」の知られざる活用方法とは? #iOS

2014.05.02 Category:技術コラム Tag: , ,

  • 91
  • このエントリーをはてなブックマークに追加
main1

iOSアプリではシングルウィンドウしかないと思われがちですが、実は随所で使われているiOSのウィンドウ「UIWindow」。

このUIWindowの知られざる登場シーンや活用法、注意点などについて、DeNAの@sintarioさんに解説レポートを寄稿していただきました。
by 馬場美由紀 (CodeIQ中の人)

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はちゃんと用意されています。

パッと見て、UIWindowUIViewのサブクラスであり、タッチイベントやフォーカス、座標変換において特別な役割を果たしているのが見てとれます。

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という見慣れない名前のウィンドウが検出されています。また、UIApplicationwindowsプロパティから得られた情報として、アプリのメインのウィンドウよりも高い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を使用したカスタムダイアログの実装方法をご紹介します。ここでは紙面の都合もあるのでサンプルコードを提示しておきます。

要点だけかいつまんで説明すると

  • 図のような、メインのウィンドウを用意してみました。「レビューをお願いします!」ボタンをタップしたらカスタムダイアログでレビューのお願いを表示したいとします。

  • storyboardでダイアログを実装します。このとき、背景となるビューのbackgroundColorclearColorに設定しておきます。

  • コードで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に注意

通常、UIViewsuperviewが参照カウントの意味でのOwnerとなって保持されるわけですが、UIWindownsuperviewはウィンドウが表示されている時でも通常 nilです。ウィンドウのインスタンスを生成して、アニメーションをかけながらmakeKeyAndVisible したとしても、誰もオーナーになっていないウィンドウは即刻リソース解放されてしまい表示されません。

UIWindowを表示している間、アプリ内のどこかで参照カウントを保持してもらう必要があります。UIApplicationUIApplicationDelegateなど、アプリ内で唯一性があってアプリとライフサイクルを共にするオブジェクトにウィンドウを所有させるのが最も単純には思いつきます。もちろんアプリによって都合の良い設計は変わってくるでしょう。

windowLevelに注意

UIWindowwindowLevelプロパティはウィンドウの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パーツについて対応関係が見いだせると捗ることもあるでしょう。どのプラットフォームでも、部品は違えども基本的な設計が似通った形にできるとおもしろいですね。

寄稿者プロフィール Shintaro Kuronuma氏
株式会社ディー・エヌ・エーで働くスマホクライアントアプリエンジニア。前職ではMac OS向けの某日本語変換ソフトの開発に従事していたが、スマホがいじりたくなって今に至る。
    現在はチラシアプリ「チラシル」の開発を担当。Twitter: @sintario

CodeIQコード銀行にあなたのコードを預けてみませんか?

  • CodeIQコード銀行ではあなたのコードを財産と考えます。
  • お預かりいただいたコードは、CodeIQコード銀行がしっかり評価し、フィードバックいたします。
  • 当コード銀行にお預けいただいたコードは、企業がみてスカウトをかける可能性があります。
  • 転職したい方や将来転職することを考えている方で、今の自分のスキルレベルを知りたい方はぜひ挑戦してみてください。
  • 企業からスカウトがきたら困る人は挑戦しないでください。

興味を持った方はこちらからチャレンジを!

  • 91
  • このエントリーをはてなブックマークに追加

■関連記事

Siriに聞いてみた!音声認識を成立させる「音響モデル」と「言語モデル」について... Siriは何でも知っている 自分自身のことでさえも… Siriの面白さの一つは問答の作り込みで、その種のコンテンツが後を絶ちませんが、検索機能にアクセスできることによって、「Google先生」に続く物知りキャラが誕生した感もありますね。 彼女は何でも知っています。 そう、自己を確立させている音...
DeNA南場智子氏が語った「経営会議より、UI/UXが大事。なぜ今デザインなのか?」... DeNAが新規事業を「やる」と決める三つの質問 何かの才能を持っている人や、ものをクリエイトできる人に対して、すごくコンプレックスを持っているという南場智子氏。なぜ今、デザインが大事なのか──その理由を、自らの失敗談とDeNAの事業戦略を交えながら語ってくれた。 ▲株式会社ディー・エヌ・...
佐々木俊尚氏を顧問に、オウンドメディアも開設!家事シェアサービス「Any+Times」は、個人のスキ... 誰かのニーズと誰かのスキルを、Webとリアルで交換 仕事で毎日遅くなるので、掃除や洗濯をする時間が取れない。そんな悩みを持つエンジニアも少なくないのではないだろうか。ハウスクリーニングサービスはいくつもあるが、安心して頼めて、かつ低コストのサービスはそうそうあるものではない。 「私自身が、便利屋...
Monacaでenchant.jsで作ったHTML5ゲームを、シュン君とスマートフォンに移植してみよ... なぜ、スマートフォンアプリ化する必要があるの? 前回までの講師は、UEI清水亮氏(前編・後編)、日本マイクロソフト物江修氏(Windowsストアアプリ編)、Mozilla Japanの清水智公氏(Firefox OS編)。 そして次なるターゲットは、スマートフォンアプリ移植。講師には、アシアル株...
作りたいアプリ・機能を一発逆引き!iOS SDK機能、フレームワーク概要まとめ #iOS8 #DeN... iOS SDK機能、フレームワーク概要のまとめと機能別逆引き CocoaPodsの登場によって、昨今のiOS開発ではとても簡単にライブラリを導入することができ、皆さんも多くの機能開発が楽になっていると思います。 ただiOS SDK自身にも非常に多くの機能やフレームワークが提供されており、外部ライ...
iOS 8で解禁されたカスタムキーボードを作ってみよう #iOS8 #DeNA... WWDC 2014のあとの進捗どうですか? WWDC 2014が終わって1カ月ほど経ちましたが、皆様いかがお過ごしでしょうか。何よりも開発者の皆様にとって衝撃的だったのはやはりSwiftの登場でしょうか。 モダンな言語に慣れ親しんだエンジニアの中には、Objective-Cの文法キモい・メソッド...

今週のPickUPレポート

新着記事

週間ランキング

CodeIQとは

CodeIQ(コードアイキュー)とは、自分の実力を知りたいITエンジニア向けの、実務スキル評価サービスです。

CodeIQご利用にあたって
関連サイト
codeiq

リクルートグループサイトへ