Xcode 13 × iOS 16 でUIHostingControllerのナビゲーションが非表示にならない問題の対処

## はじめに

UIHostingControllerでナビゲーションを非表示になるように実装していたのに、iOS 16のみナビゲーションが非表示にならず困ったため、その解決方法について記載します。

## 環境
- Xcode 13.4.1
    - Simulator: iPhone 13 mini (iOS 15.5) 
- Xcode 14.1
    - Simulator: iPhone 14 Pro (iOS 16.1) 
- 実機: iPhone 14 Pro (iOS 16.1.1)

## 現象
UIKitとSwiftUIでナビゲーションを非表示にしたViewControllerを作り、それぞれUITabBarControllerに配置します。
（後述のためにSwiftUI製のViewには中央にボタンを配置しておきます）

ナビゲーションを非表示にするために、以下の2つの処理を実行しています。

- SwiftUI側で `.navigationBarHidden(true)` をセット
- UIKit側の `viewWillAppear` で `navigationController?.setNavigationBarHidden(true, animated: true)` を実行

<details>
<summary>Code</summary>

```SwiftUIViewController.swift
final class SwiftUIViewController: UIHostingController<SwiftUIView> {
    private let viewModel = SwiftUIViewModel()
    private var cancellable: Set<AnyCancellable> = []

    init() {
        super.init(rootView: SwiftUIView(viewModel: viewModel))
        // viewModelのハンドリング
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()        
        title = "SwiftUIView w/o NavigationBar"
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.setNavigationBarHidden(true, animated: true)
    }
}

struct SwiftUIView: View {
    @ObservedObject var viewModel: SwiftUIViewModel
    
    var body: some View {
        ZStack {
            Color(.orange)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            Button(
                "Go next (with NavigationBar)",
                action: { viewModel.onTapButton.send() }
            )
        }
        .ignoresSafeArea(edges: .top)
        .navigationBarHidden(true)
    }
}
```
</details>

Xcode 13.4.1でiOS 15.5のSimulatorにビルドして確認すると、以下のような画面が表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/5202f8bc-f06b-a09c-e8db-d9eceb4e7a5b.png" width="300px"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/ee4df5b6-d5d6-35d4-4c50-4643d7bcb277.png" width="300px">

ところが、Xcode 13.4.1のDeviceSupportにiOS 16.1を追加した状態でiOS 16.1の実機にビルドすると、SwiftUIで実装した方の画面にナビゲーションが表示されてしまい、思ったような動作になっていませんでした。
Buttonを配置してナビゲーションを表示する画面と表示しない画面にpushするような実装をしてみても、同様にiOS 16.1.1ではナビゲーションが非表示になりません。ただし、一度別の画面に遷移したあとpopしてきたときにはナビゲーションが非表示になっていました。

iOS 15.5 (Simulator) | iOS 16.1.1 (実機)
:---: | :---:
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/e5b5d767-be28-65d7-34ac-dc472e421f78.gif" width="300px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/b5e9f2b4-6bca-0a2e-7583-bbcf493b6d96.gif" width="300px">

## 解決方法
### 1. Xcode 14.x でビルドする
同じコードのまま、今度はXcode 14.1でビルドしてみます。
すると、Simulator、実機ともにiOS 16以上でもナビゲーションが表示されないようになりました。

iOS 16.1 (Simulator) | iOS 16.1.1 (実機)
:---: | :---:
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/32c47e3c-bd7b-8c3a-859f-da709dfda5a7.gif" width="300px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/14e65e40-1fab-a4d2-63d8-6a460e24824d.gif" width="300px">

### 2. UIHostingControllerのviewをUIViewController上に配置する

別の事情でXcodeのバージョンを上げることができない場合、UIHostingControllerのviewを別のUIViewControllerに配置することで解決できます。

本来表示したいUIHostingController `SwiftUIViewController` の他にUIViewController `SwiftUIParentViewController` を用意し、

- `SwiftUIViewController` のviewを `SwiftUIParentViewController` のviewにaddSubviewする
- ViewModelのハンドリングを `SwiftUIParentViewController` に移植する
- `SwiftUIParentViewController` の `viewWillAppear` でナビゲーションを非表示にする
- `SwiftUIViewController` を呼び出していた箇所を `SwiftUIParentViewController` を呼び出すように変更する

の4つの対応で簡単に対処することができます。

```swift
final class SwiftUIParentViewController: UIViewController {
    private let viewModel = SwiftUIViewModel()

    init() {
        super.init(nibName: nil, bundle: nil)
        // viewModelのハンドリング
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        super.loadView()
        title = "SwiftUIView w/o NavigationBar"

        // SwiftUIViewControllerの `view` を view に addSubview する
        let vc = SwiftUIViewController(viewModel: viewModel)
        guard let childView = vc.view else { return }
        view.addSubview(childView)
        childView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            childView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            childView.topAnchor.constraint(equalTo: view.topAnchor),
            childView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            childView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // SwiftUIParentViewController 側でナビゲーションを非表示にする
        navigationController?.setNavigationBarHidden(true, animated: true)
    }
}

final class SwiftUIViewController: UIHostingController<SwiftUIView> {
    init(viewModel: SwiftUIViewModel) {
        super.init(rootView: SwiftUIView(viewModel: viewModel))
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
```

iOS 15.5 (Simulator) | iOS 16.1.1 (実機)
:---: | :---:
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/62f96547-9267-7ce6-e3c5-ee5c94ecb05a.gif" width="300px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/2a2f08ad-fb73-79f5-2d3b-a8e807beebde.gif" width="300px">

### 3. UINavigationBar上の戻るボタンだけ非表示にする

上記の例では背景を全面的にオレンジ色にしていてナビゲーションが目立ってしまうため、何がなんでもナビゲーションを非表示にしたUIを実装しようとしていました。
ただ、左上の戻るボタンを非表示にしたいけどナビゲーションは表示でも非表示でもどちらでも構わない、というケースもあるかと思います。
そのような時は、ナビゲーションを非表示にする処理を削除し、SwiftUI側で `.navigationBarBackButtonHidden(true)` をセットして戻るボタンだけ非表示にするというのも解決策の1つです。

```swift
final class SwiftUIViewController: UIHostingController<SwiftUIView> {
    private let viewModel = SwiftUIViewModel()

    init() {
        super.init(rootView: SwiftUIView(viewModel: viewModel))
        // viewModelのハンドリング
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "SwiftUIView w/o NavigationBar"
    }
}

struct SwiftUIView: View {
    @ObservedObject var viewModel: SwiftUIViewModel
    
    var body: some View {
        ZStack {
            Color(.white)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            Button(
                "Go next (with NavigationBar)",
                action: { viewModel.onTapButton.send() }
            )
        }
        .ignoresSafeArea(edges: .top)
        .navigationBarBackButtonHidden(true) // 戻るボタンだけ非表示にする
    }
}
```

iOS 15.5 (Simulator) | iOS 16.1.1 (実機)
:---: | :---:
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/89ab0305-9674-177c-94dd-20d745fc24f5.png" width="300px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/80d6efd9-7a2c-3d33-4102-9d7868394897.png" width="300px">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/ec5945ef-5958-21f4-7675-acebf1b8dec9.gif" width="300px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149055/6105127d-1588-0d05-0051-841b4c88d9ec.gif" width="300px">

iOS 15ではナビゲーションにタイトルと戻るボタンが一瞬表示されて非表示に、iOS 16ではタイトルは非表示にならず戻るボタンのみ一瞬表示されて非表示になる、という差分がありました。
この解決方法を選択する場合はタイトルをセットしないようにした方が良さそうです。

## おわりに
新しいOSが公開されるのをいつもとても楽しみにしていますが、その反面アプリ開発者としては謎の不具合との出会いに悪戦苦闘したりします。
よりよい解決方法を模索しながら楽しく開発していきたいですね :muscle: