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

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

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

## 環境
- XCode 9.2
- Swift 4.0.3

## サンプルアプリ
https://github.com/takehilo/CustomNavigationBarTest

![CustomNavigationBar.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/798ea0fe-8a5c-9598-5a4b-80570af37254.gif)

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/9377e0e1-868e-9aad-58bd-b7981d78640a.png)

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/164f3b94-c659-9d10-186b-ec520a61d9e9.png)


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

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

```swift: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](https://qiita-image-store.s3.amazonaws.com/0/81715/02296ef1-d26a-51e8-6571-cdec847df0d6.png)

遷移先の画面。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/80c0fdd1-35f6-e7aa-bfc1-e49d29f98947.png)

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

```swift:ViewController.swift
import UIKit

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

```swift: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](https://qiita-image-store.s3.amazonaws.com/0/81715/d9ac0f72-4517-2faa-1f42-be71320d6cd2.png)

遷移先の画面。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/6e600392-3dfc-7933-74a4-2c82a19c18c5.png)

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

```swift: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](https://qiita-image-store.s3.amazonaws.com/0/81715/baff1220-9b6a-6e47-6e7c-cb6b65ec0a03.png)

## カスタムナビゲーションバークラスをiOS11に対応させる
iOS11では、上述した方法ではナビゲーションバーの高さは変更されません。
代わりに、`layoutSubviews()`をオーバーライドした実装をしてあげる必要があります。
ここは[StackOverflow](https://stackoverflow.com/questions/44387285/ios-11-navigation-bar-height-customizing)で紹介されていた方法を利用しました。

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/81715/0c93884b-4a04-f212-8980-6c1f758a81dd.png)

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

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

```swift: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)
        }
    }
}
```

```swift: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](https://qiita-image-store.s3.amazonaws.com/0/81715/263be781-cb75-1d7c-0660-6757d9e85470.png)

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

![CustomNavigationBar2.gif](https://qiita-image-store.s3.amazonaws.com/0/81715/3be4c03c-7dcd-8ed5-2a55-a03be1dceaa1.gif)

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

```swift: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()
    }
}
```

```swift: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