Swiftにおける拡張したナビゲーションバーのアイテム位置調整

# はじめに

「ナビゲーションバーの高さを変更したら、タイトルとアイテムの位置がおかしくなった🥺」

本記事では、Swiftにおける **拡張したナビゲーションバーのアイテム位置調整手法を2つ** 紹介し、各特徴を比較します。

**🎯 想定読者**
- ナビゲーションバーの高さをカスタマイズしたいiOSエンジニア

**🎯 この記事で分かること**
- ナビゲーションバー高さ変更時の問題と解決策
- 2つの実装手法とその比較



**🚨 注意**　

本記事は技術書典17で出版した「24年度新卒 エンジニア クックブック」の第8章の内容をQiita用に書き直したものになります。

# 背景

Swiftでナビゲーションバーの高さを任意に変更したい場合、**拡張時におけるナビゲーションアイテムの位置調整** が課題になります。

**問題の具体例**

下記コードは、ナビゲーションバーの高さをカスタマイズするUINavigationBarのサブクラスです。

```swift
public class HeightAdjustableNavigationBar: UINavigationBar {
    // ナビゲーションバーの高さを指定（例では70pt）
    public var preferredHeight: CGFloat = 70
    
    // UINavigationBarのframeプロパティをオーバーライドして高さを上書き
    override public var frame: CGRect {
        get {
            super.frame
        }
        set {
            var frame = newValue
            frame.size.height = preferredHeight
            super.frame = frame
        }
    }
}
```

このように実装したナビゲーションバー上にタイトルとアイテムを設定すると、以下のような問題が発生します。

![1_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2712757/b8174ab1-5f3e-4ae3-91dc-96b96f5bc760.png)


**問題点**
ナビゲーションバーの高さの中央（例では35pt位置）から上部にコンテンツが外れている

**原因**
UINavigationBarは標準的な高さ（iPhoneの場合44pt）でレイアウトされるように設計されているため、高さを変更する場合はタイトルやアイコンの位置を明示的に調整する必要がある


# UINavigationBarContentView

1つ目の解決手法として、ナビゲーションバーのコンテンツビューである**UINavigationBarContentView** を用いた整列手法を紹介します。

**実装コード**

```swift
public class HeightAdjustableNavigationBar: UINavigationBar {
    public var preferredHeight: CGFloat = 70
    
    override public var frame: CGRect {
        get { super.frame }
        set {
            var frame = newValue
            frame.size.height = preferredHeight
            super.frame = frame
        }
    }

    //  layoutSubviewsメソッドをオーバーライド
    override public func layoutSubviews() {
        super.layoutSubviews()

        // ① ナビゲーションバーの全てのサブビューをループ
        for subview in subviews {
            // ② クラス名を文字列として取得
            let className = NSStringFromClass(subview.classForCoder)
            
            if className.contains("UINavigationBarContentView") {
                // ③　垂直方向の中心 (center.y) をナビゲーションバーの高さの中央に合わせる
                subview.center.y = bounds.height / 2
            }
        }
    }
}
```

**💡 実装のポイント**

1. **①** ナビゲーションバーの全ての直接のサブビューを順に処理
2. **②** サブビューのクラス名を文字列として取得（`subview.classForCoder` でエンコーディング用クラスを取得）
3. **③** UINavigationBarContentViewに対して、ナビゲーションバーの垂直方向中心位置に調整

この実装により、タイトルとボタン共に横方向に整列した状態で期待値通りの高さ調整が可能になります。

![2_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2712757/62157679-7497-489e-8c5c-90aabe3a0546.png)

# スペーサーとUIStackView

2つ目の手法として、**スペーサーとUIStackView** を用いたナビゲーションアイテムの位置調整手法を紹介します。

**共通のスタックビュー定義**

```swift
/// ナビゲーションタイトルやボタンにスペーサーを挿入し、位置調整を行うための共通スタックビュー
class ViewUtils {
    /// - Parameters:
    ///   - spacerHeight: スペーサー（空白）の高さ
    ///   - contentView: スペーサーの下に配置するコンテンツ（タイトルラベルやボタン）
    ///   - totalHeight: スタックビュー全体の高さ
    static func createVerticalStackView(
        spacerHeight: CGFloat,
        contentView: UIView,
        totalHeight: CGFloat
    ) -> UIStackView {
        // ① UIStackView の作成
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.alignment = .center
        
        // ② スタックビューの高さ制約
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.heightAnchor.constraint(equalToConstant: totalHeight).isActive = true
        
        // ③ 高さを spacerHeight に固定した空の UIView を作成
        let spacer = UIView()
        spacer.translatesAutoresizingMaskIntoConstraints = false
        spacer.heightAnchor.constraint(equalToConstant: spacerHeight).isActive = true
        
        // ④ スペーサーの下に contentView を追加
        stackView.addArrangedSubview(spacer)
        stackView.addArrangedSubview(contentView)
        
        return stackView
    }
}
```

**タイトルとボタンの設定**

```swift
func makeUIViewController(context: Context) -> UINavigationController {
    // 省略 -----

    /// ナビゲーションタイトルを設定し、その上部にスペーサーを挿入して位置を調整する関数
    private func setTitle(on navigationItem: UINavigationItem, title: String?, textColor: UIColor) {
        let titleLabel = UILabel()
        titleLabel.text = title
        titleLabel.font = UIFont.boldSystemFont(ofSize: 16)
        titleLabel.textColor = textColor
        
        let stackView = ViewUtils.createVerticalStackView(
            // テキスト上のスペーサーの高さを設定
            spacerHeight: 25,
            contentView: titleLabel,
            // ナビゲーションバー全体の高さを設定（例では70pt）
            totalHeight: 70
        )
        
        // 作成したスタックビューを navigationItem.titleView に設定
        navigationItem.titleView = stackView
    }
    
    /// 左右のナビゲーションボタン（アイコン）を作成し、その上部にスペーサーを挿入して位置を調整する関数
    private func createBarButton(imageName: String) -> UIBarButtonItem {
        let button = UIButton(type: .system)
        button.setImage(UIImage(systemName: imageName), for: .normal)
        button.tintColor = .white
        
        // 画像のコンテンツモードを設定して比率を維持
        button.imageView?.contentMode = .scaleAspectFit
        button.translatesAutoresizingMaskIntoConstraints = false
        
        let stackView = ViewUtils.createVerticalStackView(
            spacerHeight: 25,
            contentView: button,
            totalHeight: 70
        )
        
        // 作成したスタックビューをカスタムビューとして UIBarButtonItem に設定
        return UIBarButtonItem(customView: stackView)
    }
}
```

**💡 実装のポイント**

この手法では、**スペーサーとコンテンツビューを含む垂直方向のUIStackView** を作成して調整を行います。

1. **①** 器となるスタックビューを作成
2. **②** 高さに制約を設定
3. **③** 空のUIViewを作成し、高さ`spacerHeight`に固定したスペーサーとして使用
4. **④** スペーサーの下にナビゲーションタイトル・ボタンが挿入される `contentView` を追加

スペーサーの高さを自由に設定できるため、**タイトルとボタンそれぞれに異なる位置を指定** することも可能です。


# 2つの手法の比較

それぞれの手法のメリット・デメリットを比較してみましょう。

**🎯 UINavigationBarContentView**

**メリット**
- **実装が容易**：layoutSubviewsをオーバーライドし、UINavigationBarContentViewの位置を調整するだけ
- **ナビゲーションバーのサイズ変更に自動対応**：高さを変更した際、コンテンツの位置が自動的に調整される。特定の高さに限定されず、仕様変更に強い

**デメリット**
- **非公開APIの使用**：UINavigationBarContentViewは非公開クラスで、将来的なiOSアップデートで動作が変わる可能性がある（執筆時iOS18では動作確認済み）
- **個別調整の難しさ**：ナビゲーションアイテム全体をまとめて位置調整するため、タイトルとボタンを個別に異なる位置に配置することが難しい

**🎯 スペーサーとUIStackView**

**メリット**
- **公式APIのみを使用**：非公開APIを使用していないため、iOSアップデートによる予期せぬ動作変更のリスクが低い
- **柔軟性の高さ**：タイトルとボタンそれぞれに異なるスペーサーの高さを設定でき、個別に位置を調整することが容易

**デメリット**
- **実装が複雑**：UIStackViewとスペーサーを用いた実装は、コード量が多く、可読性が低い
- **ハードコーディングのリスク**：ナビゲーションバーの高さやスペーサーの高さを固定値で設定する必要があるため、仕様変更に弱い

# 推奨手法とまとめ

2つの手法にはそれぞれメリットとデメリットがありますが、個人的には **スペーサーとUIStackViewを用いた手法** を推奨します。

**推奨理由**
- **App Storeリジェクトリスクの回避**：非公開API使用によるリジェクトを避けられる
- **長期的な安定性**：iOSアップデートによる予期せぬ動作変更のリスクが低い
- **柔軟な調整**：個別のアイテム位置調整が可能

コード量は多くなりますが、**予期せぬ動作やApp Storeでのリジェクトリスク** を避けることができます。

**まとめ**

本記事では、Swiftにおける拡張したナビゲーションバーのアイテム位置調整手法を2つ紹介しました。

- **UINavigationBarContentView手法**：実装は簡単だが非公開API使用のリスクあり
- **スペーサー + UIStackView手法**：実装は複雑だが公式APIのみで安全

プロダクトの長期運用を考えると、**安全性を重視した手法** を選択することをお勧めします。

**参考**
- [Apple Developer Documentation - UINavigationBar](https://developer.apple.com/documentation/uikit/uinavigationbar)
- [Apple Developer Documentation - UIStackView](https://developer.apple.com/documentation/uikit/uistackview)
- [iOS10とiOS11両方に対応したナビゲーションバーを作る方法](https://qiita.com/takehilo/items/be1e055d8d841ef8fc2a)