iOS
Swift
ios10
swift4
ios11

iOS10とiOS11両方に対応したナビゲーションバーを作る方法

ナビゲーションバーの高さをデフォルトから変更したかっただけなのですが、iOS10とiOS11でナビゲーションバーの仕様が違うために色々と苦労したので、ここに備忘録としてまとめておきます。

やりたいこと

  • ナビゲーションバーの高さをデフォルトから変更したい
  • iOS10とiOS11に対応したい

環境

  • XCode 9.2
  • Swift 4.0.3

サンプルアプリ

https://github.com/takehilo/CustomNavigationBarTest

CustomNavigationBar.gif

画面構成

NavigationControllerを使って2つのViewController間で画面遷移をするアプリを考えます。

image.png

以下で説明するCustomNavigationBarクラスを、NavigationController配下のNavigationBarのCustom Classとして指定してください。

image.png

カスタムナビゲーションバークラスを作成する

ナビゲーションバーの高さを変更するには、UINavigationBarクラスを継承したカスタムクラスを作成する必要があります。

iOS10では、sizeThatFits()メソッドで高さを指定します。
以下のように指定の高さを返すようにしてあげるだけで、ナビゲーションバーの高さは変更されます。

CustomNavigationBar.swift
import UIKit

class CustomNavigationBar: UINavigationBar {
    let barHeight: CGFloat = 100

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        return CGSize(width: UIScreen.main.bounds.width, height: barHeight)
    }
}

しかし、これだとナビゲーションバー上のタイトルなどの位置が以下のようになってしまいます。

image.png

遷移先の画面。

image.png

タイトルや戻るボタンの位置を調整する

タイトルや戻るボタン(UIBarButtonItem)のY軸方向の位置を調整するには、setTitleVerticalPositionAdjustment()setBackgroundVerticalPositionAdjustment()というメソッドを利用します。

ViewController.swift
import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
    }
}
SecondViewController.swift
import UIKit

class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.leftBarButtonItem = UIBarButtonItem(image: #imageLiteral(resourceName: "ic_arrow_back"), style: .plain, target: self, action: #selector(back))
        navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
        navigationItem.leftBarButtonItem?.setBackgroundVerticalPositionAdjustment(-20.0, for: .default)
    }

    @objc func back() {
        navigationController?.popViewController(animated: true)
    }
}

image.png

遷移先の画面。

image.png

1点注意なのが、UIBarButtonItemに関しては画像が指定されている必要があることです。
以下のように文字列を指定していると位置の変更がされません。

SecondViewController.swift
import UIKit

class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // navigationItem.leftBarButtonItem = UIBarButtonItem(image: #imageLiteral(resourceName: "ic_arrow_back"), style: .plain, target: self, action: #selector(back))
        navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(back))
        navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
        navigationItem.leftBarButtonItem?.setBackgroundVerticalPositionAdjustment(-20.0, for: .default)
    }

    @objc func back() {
        navigationController?.popViewController(animated: true)
    }
}

image.png

カスタムナビゲーションバークラスをiOS11に対応させる

iOS11では、上述した方法ではナビゲーションバーの高さは変更されません。
代わりに、layoutSubviews()をオーバーライドした実装をしてあげる必要があります。
ここはStackOverflowで紹介されていた方法を利用しました。

CustomNavigationBar.swift
import UIKit

class CustomNavigationBar: UINavigationBar {
    let barHeight: CGFloat = 100

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        return CGSize(width: UIScreen.main.bounds.width, height: barHeight)
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        if #available(iOS 11.0, *) {
            for subview in subviews {
                let stringFromClass = NSStringFromClass(subview.classForCoder)

                if stringFromClass.contains("UIBarBackground") {
                    subview.frame = CGRect(x: 0, y: 0, width: frame.width, height: barHeight)
                    subview.sizeToFit()
                }

                if stringFromClass.contains("UINavigationBarContentView") {
                    let centerY = (barHeight - subview.frame.height) / 2.0
                    subview.frame = CGRect(x: 0, y: centerY, width: frame.width, height: subview.frame.height)
                    subview.sizeToFit()
                }
            }
        }
    }
}

この実装が何をやっているかは、画面をデバッグしてみるとよくわかります。

UINavigationBarは、配下にUIBarBackgroundUINavigationBarContentViewというビューを持っています。
緑色の部分がUIBarBackgroundビューで、この部分を指定したい高さに設定しています。
黄色の部分はUINavigationBarContentViewビューで、配下にUILabelを持っています。
タイトルの位置をナビゲーションバーの真ん中の高さに来るように見せたいために、このビューの位置をUIBarBackgroundの真ん中に来るように設定しています。
UINavigationBarContentViewにもUIBarBackgroundと同じ設定をすれば良いように思いますが、UINavigationBarContentViewは高さ変更がどうやらできないらしく、このような方法を取っています。

image.png

SafeAreaを広げる

デバッグ画面からわかるように、このままだとViewController配下のView(青色部分)がナビゲーションバーにかぶってしまいます。
上記のカスタムナビゲーションバークラスの実装では、ナビゲーションバーの高さ自体は変更されていないため、SafeAreaのサイズも変わりません。
そこで、ViewController側で自身のViewのSafeAreaサイズを拡張して上げる必要があります。

以下のように、ナビゲーションバーのデフォルトの高さからの増分を、additionalSafeAreaInsets.topに与えてあげます。
こうすることでSafeAreaのTop側が拡張され、Viewがかぶらないようになります。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        if #available(iOS 11.0, *) {
            additionalSafeAreaInsets.top = 100 - 44
        } else {
            navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
        }
    }
}
SecondViewController.swift
import UIKit

class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.leftBarButtonItem = UIBarButtonItem(image: #imageLiteral(resourceName: "ic_arrow_back"), style: .plain, target: self, action: #selector(back))

        if #available(iOS 11.0, *) {
            additionalSafeAreaInsets.top = 100 - 44
        } else {
            navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
            navigationItem.leftBarButtonItem?.setBackgroundVerticalPositionAdjustment(-20.0, for: .default)
        }
    }

    @objc func back() {
        navigationController?.popViewController(animated: true)
    }
}

image.png

仕上げ

これで終わりと思いきや、上記の実装だとiOS11で画面遷移の際にナビゲーションバーが一瞬縮むような動きが出てしまいます。

CustomNavigationBar2.gif

解決策として、ViewControllerのviewWillAppear()メソッドでナビゲーションバーのsizeToFit()メソッドを呼ぶようにしています。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        if #available(iOS 11.0, *) {
            print(additionalSafeAreaInsets)
            additionalSafeAreaInsets.top = 100 - 44
        } else {
            navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
        }
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.navigationBar.sizeToFit()
    }
}
SecondViewController.swift
import UIKit

class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.leftBarButtonItem = UIBarButtonItem(image: #imageLiteral(resourceName: "ic_arrow_back"), style: .plain, target: self, action: #selector(back))

        if #available(iOS 11.0, *) {
            additionalSafeAreaInsets.top = 100 - 44
        } else {
            navigationController?.navigationBar.setTitleVerticalPositionAdjustment(-20.0, for: .default)
            navigationItem.leftBarButtonItem?.setBackgroundVerticalPositionAdjustment(-20.0, for: .default)
        }
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.navigationBar.sizeToFit()
    }

    @objc func back() {
        navigationController?.popViewController(animated: true)
    }
}

なぜこれで解決できるのかはいまいち理解できていないので、わかる方いたらコメントいただけると嬉しいです。

わかっていることとしては、viewWillAppear()sizeToFit()を呼ぶと、カスタムナビゲーションバークラスのsizeThatFits()が呼ばれ、ナビゲーションバーの高さが一時的に指定の値(ここでは100)になります。
最終的にナビゲーションバーの高さは元の44に戻ってしまうのですが、この一時的に指定の高さに変更するというのがポイントなのでしょうか。

まとめ

だいぶ長くなりましたが、ナビゲーションバーの高さをデフォルトから変更する方法について説明しました。
やりたいことは至って単純なのに、結構手間がかかりますね。
もっとシンプルで良いやり方があったら、是非教えてください:grin:

参考

https://stackoverflow.com/questions/44387285/ios-11-navigation-bar-height-customizing