Swift UIWindow 活用術 〜やめよう!最前画面の取得〜

この記事はアイスタイル アドベントカレンダー25日目の記事です。

ご挨拶

メリークリスマス!なんて絶対に言いません。yukitです。
iOSエンジニアとして@cosmeのアプリ開発チームにアサインして4ヶ月が経ちました。
これからもグロースし続けるサービスに携われるのは嬉しいことです。
ではクリスマスに媚びることなく平常心でSwiftのTipsを紹介していきます。

アプリの最前の画面をいつでも取得できたら便利なんだけど…

過去いろいろなiOSプロジェクトに参画する中で度々目にしたのが
「現在表示中の最前画面クラスを取得する」というロジックです。例えばこんなの

func getFrontViewController() -> UIViewController? {
    var vc = UIApplication.shared.keyWindow?.rootViewController
    while vc?.presentedViewController != nil {
        vc = vc?.presentedViewController
    }
    return vc
}

用途としては以下が主でしょうか。

  • 最前画面を取得してローディング中ステータスを画面全体に表示したい
  • 最前画面を取得してエラーアラートを簡単に表示できるようにしたい
  • 現在の最前画面を気にすることなくキャンペーン系のポップアップを一番上に被せて表示したい

確かにこういった高頻度で発生するシーンでは、いつでも簡素的にアプリの最前画面クラスを取得するロジックが欲しくなります。が、落とし穴があります。

まずはその脆弱性を考察していきます。

最前画面クラスを取得することの脆弱性

/// 現在の最前面に表示中の画面クラスを取得する
func getFrontViewController(vc: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
    if let presented = vc?.presentedViewController {
        return getFrontViewController(vc: presented)
    }
    return vc
}

UIApplication を経由して、モーダル表示 ( present ) 中の画面が存在するかを、ルート画面から再起的にチェックし、最終的に最前画面クラスを取得しています。
しかしこのパターンでは UINavigationControllerUITabBarController の存在が考慮されていません。

「自分のプロジェクトはTabBar使ってないし大丈夫」ということもあるかもしれませんが、
ユーザーニーズの変化が激しい今日では、その判断は危険かもしれません。
1年後にタブバーベースのレイアウトにリファクタです!となったときに
このロジックの改修を忘れずにいられるでしょうか。

となると、以下のようにする必要があります

/// 現在の最前面に表示中の画面クラスを取得する
func getFrontViewController(vc: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
    if let navigationController = vc as? UINavigationController {
        return getFrontViewController(vc: navigationController.visibleViewController)
    }
    if let tabBarController = vc as? UITabBarController,
        let selectedViewController = tabBarController.selectedViewController {
        return getFrontViewController(vc: selectedViewController)
    }
    if let presentedViewController = vc?.presentedViewController {
        return getFrontViewController(vc: presentedViewController)
    }
    return vc
}

だいぶソースが混み合ってきました。

これで要件が満たせたかのように思えますが、まだ安心できません。
最前画面クラスが取得できて、その画面上で何かを実行できたとしても
その画面クラスが途中で死なない( dismiss されない)保証ができません。
ユーザの操作は基本的に予測し切れないのが常ですし、機能要件が複雑化すれば尚更です。

更に懸念されるのは、この function がオープンなスコープで公開されている時です。
ビジネスロジックを扱う階層からでも呼び出せることは、アーキテクチャ崩壊の第一歩です。

以上を踏まえると、何か別のプラクティスが必要になってきます。

目指すべきベストプラクティス

まず大前提、高頻度で利用されることを想定し、呼び出しが簡素であることは揺らぎたくありません。
また、「現在画面がどんな状態か、もしくはこれからどう状態遷移するのか」を関心ごととしたくもありません。
更に言うと、アプリの最前面での振る舞い(ローディング中表示等)がメインコンテンツに干渉を及ぼす、
またはメインコンテンツから干渉を受ける可能性がある状態も避けるべきでしょう。

まとめると…

  • 呼び出しが簡素である
  • 現在の画面の状態に依存することなく常に最前面での振る舞いを定義できる
  • 最前面での振る舞いがメインコンテンツに干渉しない

となります。わがままです。
ですが、UIWindowを活用すれば これら全てを満たすことができます。

UIWindowを活用する

前置きが長くなりましたが、タイトルを回収していきます。
「ローディング中ステータスを表示する機能(以下ローディング表示)の実装」を例にします。

1. ローティング表示専用のUIWindowを用意する

メインコンテンツが乗っているUIWindowとは別の、ローディング表示専用のUIWindowサブクラスを用意します。

import UIKit

final class LoadingWindow: UIWindow {
    // このWindowに対するタップ判定を透過させる
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        if view == self { return nil }
        if view == rootViewController?.view { return nil }
        return view
    }
}

メインコンテンツが乗るUIWindowとは別のWindowを定義することで

現在の画面の状態に依存することなく常に最前面での振る舞いを定義できる

を満たすことができました。
ここでポイントとなるのは、このカスタムWindowに対するタップ判定を透過させることです。(理由は後述)

2. ローティング表示専用UIWindowを初期化する

AppDelegate でこのUIWindowを初期化します

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    // メンバ変数としてインスタンス生成
    private lazy var loadingWindow: LoadingWindow = LoadingWindow(frame: UIScreen.main.bounds)

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

        loadingWindow.rootViewController = LoadingViewController()
        loadingWindow.windowLevel = UIWindow.Level.normal + 1 // <- ポイント?
        loadingWindow.isHidden = false

        return true
    }
}

loadingWindow.windowLevel を「+1」することにより、メインコンテンツが乗っているUIWindowよりも、Zポジションを一つ手前にすることが可能です。つまり最前面へのコンタクトが可能になります。
更に項番1の段階でLoadingWindowへのタップ判定を透過させているので、メインコンテンツへのインタラクションを妨害する心配もありません。

最前面での振る舞いがメインコンテンツに干渉しない

を満たすことができました。

3. ローティング表示画面クラスを実装する

import UIKit
import RxSwift
import RxCocoa

let isLoading: BehaviorRelay = BehaviorRelay<Bool>(value: false)

final class LoadingViewController: UIViewController {
    private let loadingView = LoadingView() // ローディング表示のView(クルクルさせたりお好みで)
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        // レイアウトは割愛
        view.addSubview(loadingView)

        isLoading
            .asDriver()
            .distinctUntilChanged()
            .drive(onNext: { [weak self] isLoading in
                if isLoading {
                    self?.loadingView.startAnimating()
                } else {
                    self?.loadingView.stopAnimating()
                }
            })
            .disposed(by: disposeBag)
    }
}

// -- 呼び出し例

public func someAPICall() {
    isLoading.accept(true)

    API.call(url) { result in 
        isLoading.accept(false)
        // result handling ...
    }
}

ローディング表示はアプリの要件によって振る舞いが様々なので、ほぼ余談です。
呼び出し例の通り

呼び出しが簡素である

ことがわかると思います。

重要なのは「ローディング開始/終了」のイベントを通知するということです。
今回の例では、弊社istyleのアプリ開発において標準スキルとして採用している
RxSwift(RxCocoa) を用いた通知を行っていますが、NotificationCenter などそれぞれのプロジェクトの設計にマッチした機構を選択できます。

etc 副次的なおいしいところ

この設計を採用すると、MVVM, MVP, Flux, Redux, 最近ではオニオンなど
様々なアーキテクチャにおいて、ローディング表示/非表示をUI層で指示しなくてよくなります
iOS開発では “FatViewController” なんて言葉が存在するくらい、UI層のコードが肥大化しやすいです。
おいしいです。

まとめ

UIWindowを独自に定義することにより、メインコンテンツへの干渉や依存を気にすることなく、
アプリの最前面での振る舞いが実現できました。windowLevelをコントロールすることで、
「アラート表示の共通ヘルパー機能」や「ポップアップ表示機能」などなど、様々な場面でUIWindowが活躍できそうです。

それでは皆様、メリークリスマス?

アプリエンジニア募集中

募集中です!

  • 多種多様な新規開発案件
  • 大規模プロジェクトの運用
  • 最新環境によるリモートワーク
  • スマホだけでなくiPadアプリ開発も
  • etc…

少しでも興味が湧いたら、まずはお話聞きに来ませんか?
中途採用 | istyle 株式会社アイスタイル

コメントを残す