2016年11月10日

[開発者向け] UINavigationController と生きていく

 ほら、iPhone って戻るボタンないじゃないすか。
 だからアプリを作る場合、前の画面に戻るためのUIは必要に応じて自分で実装しないといけないんですが、一応、自動的に画面の履歴を管理して、1つ前の画面に戻るボタンも表示してくれる、UINavigationController って機能が用意されてます。
 それを使えば Android と同じような感じのアプリを作りやすいのですが、なんかどうにも微妙に不親切なので、罠っぽいところをまとめときます。

 それはそうと、UINavigationController っていちいち書くのダルイよね。iOS の開発してるとどうしても 「〜Controller」 って名前が多くなるんで、いちいち名前が長くなって鬱陶しい。

UINavigationController の使い方
 基本的な使い方については、他にいくらでも親切な記事がありますので他の方にお任せします。
 Xcode の右下にも Navigation Controller ってパーツがあるのですが、アレは余計なオマケ付きで使いにくすぎるので、メニューから Editor > Emned in で追加するのがお勧めです。  初めて UINavigationController の使い方を見た時は、てっきり 「これは履歴管理機能をセットアップためだけに通り抜ける隠し画面なのかね?」 とか勝手に誤解して勝手に気持ち悪がったもんですが、UINavigationController が親で、表示する画面群が子、という親子関係だったんですね。各画面の parent プロパティは、1つ前の画面ではなく UINavigationController を指してます。
20161109a.png
 ちなみに、storyboard を複数に分けて開発してる場合も、以下みたいにすれば同じ UINavigationController で管理できます。この場合、新たにロードする storyboard の方には UINavigationController を繋ぐ必要は必要ありません。
swift3
if let vc = UIStoryboard( name: "storyboard名", bundle: nil )
    .instantiateInitialViewController()
{
    show( vc, sender: nil )
    // もしくは以下でも可。show() は状況を判断して以下を呼んでる。
    // navigationController?.pushViewController( vc, animated: true )
}
1つ前の画面を取得したいんだけど?
簡単に取得できないんだなこれが
 ドン引きだよ何この役立たず!。1つ前の画面を取得したい場合、UINavigationController の画面スタック、viewControllers の中から、自分の画面の1つ前の画面を探してこないといけません。以下みたいに extension にしちゃえば、どこでも流用できて便利です。
swift3
public extension UIViewController
{
    public func getPreviousViewController() -> UIViewController?
    {
        if let vcList = navigationController?.viewControllers
        {
            var prevVc: UIViewController?;
            for vc in vcList
            {
                if ( vc == self ) { break }
                prevVc = vc
            }
            return prevVc
        }
        // ここに来るのは実装ミスなので assert でもよろし
        return nil
    }
}

 ちなみに、ネットで探すと 「viewControllers の末尾から2番目を取得すればOK!」 みたいな記事をよく見かけるのですが (つーか 公式ドキュメントにまでそう書いてるらしいorz)、この方法だとタイミングによっては2つ前の画面を取得しちゃうのでやめましょう。どうにも画面スタックに登録や削除されるタイミングが自分勝手なんですよ。
画面表示時ライフサイクル
  • viewDidLoad接続前なので navigationController が nil (storyboard だと登録済の事もある)
  • willMove(toParentViewController:parent) > ここ以後はスタックに登録済
  • viewWillAppear > 登録済
  • viewDidAppear > 登録済
Back 押下時ライフサイクル
  • willMove(toParentViewController:nil) > ここまでは登録済
  • viewWillDisappearスタックから削除されてる
  • viewDidDisappearnavigationController が nil
 前の画面から情報を受け取りたい viewDidLoad や、リザルトを返したい viewWillDisappear が罠になってるのがタチ悪いですね。安全にアクセスしたいのなら、viewWillAppear とか、スタックが安定してるタイミングで取得して、メンバに保持しておくのが安全かと。
 それと viewDidLoad については、呼び出されるタイミングがブラックボックス過ぎなので、自分や子の初期化だけにして、親や前の画面へのアクセスは避けた方がよさそう。
Back押した時に何か処理したいんだけど
簡単にできないんだなこれが
 ドン引きだよ何この役立たず!。なんでか、Back を押された事を直接知る手立ては本気でないようですので、viewWillDisappearviewDidDisappear で処理する事になります。が、これらは別の画面への遷移時などにも呼ばれるので、前の画面に戻り中なのか判別しないといけません。

 で、これまたネットで探すと、viewWillDisappear 内で、画面スタックに自分がないなら戻り中です!」 という、バッドノウハウな予感しかしない記事がいっぱい見つかるのですが、現在の挙動がたまたまそうってだけだし、どちらかというとまだ表示中なのに履歴から消されてる事の方が不自然なので、そんな方法で判断したくありません。

 正解は、戻る際には UIViewController の isMovingFromParentViewController というプロパティが true になるので、大抵の場合はこれで判断するのが確実なのですが、最初見た時 MovingFrom の意味が掴めなくて困った。RemoveFrom とか にしてくれよ。
 そしてさらにウザい事に、UINavigationController などに管理されてる画面とそうでない画面で、判断の仕方が違うんですよね。
isMovingToParentViewController
親 ViewController に接続されて初めて表示されるまでの一連の処理中は true
isMovingFromParentViewController
親 ViewController から切り離されて画面を閉じるまでの一連の処理中は true
isBeingPresented
presentViewController で画面を開く際の一連の処理中は true
isBeingDismissed
dismiss で画面を閉じる際の一連の処理中は true
 と、なんかもう絶望的に不便だし覚えにくいしで、結局また覚えやすい extension 作っちゃいましたけどね。これで困る時は個別に使えばいいんだし。
swift3
public extension UIViewController
{
    public final var isPresenting : Bool
    {
        return isBeingPresented || isMovingToParentViewController
    }

    public final var isDismissing : Bool
    {
        return isBeingDismissed || isMovingFromParentViewController
    }
}
画面スワイプでも戻れるんだ。そう、iPhoneならね!
 上記で前の画面に戻る際の処理も記述できるようになりましたが、実は UINavigationController 管理下の画面は、Back ボタンのほかに、画面左端からスワイプでも前の画面に戻れます
20161109b.png
 まあ分かりやすいし、いいんじゃないすかねとは思うのですが、困った事にこのスワイプ、途中でやめて左に戻せてしまいます。この時、ライフサイクル的には何が起きてるかというと、
Backボタン、または正常にスワイプしきった場合
<スワイプ開始時>
  • 現在の画面の willMove:toParentViewController:
  • 現在の画面の viewWillDisppear (isMovingFromParentViewController = true)
  • 前の画面の viewWillAppear
<スワイプ終了〜アニメーション終了時>
  • 現在の画面の viewDidDisppear (isMovingFromParentViewController = true)
  • 現在の画面の didMove:toParentViewController:
  • 前の画面の viewDidAppear
スワイプを途中でやめた場合
<スワイプ開始時>
  • 現在の画面の willMove:toParentViewController:
  • 現在の画面の viewWillDisppear (isMovingFromParentViewController = true)
  • 前の画面の viewWillAppear
<スワイプ中断時 (アニメーション関係なく、中断した瞬間)>
  • 前の画面の viewWillDisappear
  • 前の画面の viewDidDisappear
  • 現在の画面の viewWillAppear (isMovingToParentViewController = true)
  • 現在の画面の viewDidAppear (isMovingToParentViewController = true)
な ん か い ろ い ろ と 乱 暴 ス ギ ィ!
 さあーぁ、この挙動を把握してないといろんな事が起きますよ!
  • viewWillDisAppear で前の画面にリザルトを返す場合、それが何度も呼ばれても問題ない作りにしないといけません。
    viewDidDisappear でなら本当の終了時にだけリザルトを返せますが、前の画面に完全に戻ってから画面に反映させる事になるので、見栄えはよくないです。
  • viewDidDisappear に終了処理などを書く場合、そこを通らずに viewWillAppear から再初期化しても問題ない作りにしないといけません。
    それに限らず、viewWill〜 と viewDid〜、willMove〜 と didMove〜 が必ず対で呼ばれる前提の処理は一切組めません
  • viewWillAppear で状態の初期化などを行っていると、スワイプ中断時にも初期化されてしまう事になります。ユーザーとしては続きから操作をするつもりが最初からやり直しでイラっときますので注意が必要です。
まとめの愚痴
 とまあ、いろいろ不親切とはいえ、自動的に画面履歴と Back ボタンを管理してくれるのは便利なので、今後も UINavigationController にはお世話になるとは思うのですけど、今日のどの件に関しても、というかiOS の相手しててちょくちょく思うのだけど、どうにも詰めが甘いままデベロッパに丸投げになってる仕様が多くて、ちょくちょく頭を抱えます。
 ボクの考えた素晴らしいアイディアを次々作るのもいいけど、ちゃんと詰めてほしいもんですね。しょーもないバグも多いし。

posted by ひこざ at 23:59| Comment(0) | 開発 - iPhone
この記事へのコメント
コメントを書く
お名前:

メールアドレス:

ホームページアドレス:

コメント:

認証コード: [必須入力]


※画像の中の文字を半角で入力してください。