UIKitからSwiftUIの画面に遷移した場合の戻るボタンの見た目を合わせる

## はじめに

iOSアプリ開発でUIKitとSwiftUIを組み合わせて使うことも出てきました。
`UIHostingController`を使って`UIKit`の`UINavigationController`から`SwiftUI`の画面に遷移するのも、もはや定番の手法です。

しかし、この時ナビゲーションバーの「戻るボタン」の位置がUIkitとSwiftUIでズレる問題があります。
このままだとナビゲーションの統一感がなくなってしまい、UI的に違和感が出ることも…。

そこで、本記事では`UIKit`の戻るボタンを`SwiftUI`の`NavigationStack`に寄せる方法を解説します！✨

環境: Xcode 16.2, iOS 18.3

## というわけでコードです

紹介するコードは以下の様な画面遷移となっています。

* UINavitionControllerの中にViewControllerがある
* ViewControllerからViewControllerBに遷移できる（UIKit→UIKit遷移）
* ViewControllerBからUIHostingController(ContentViewC)に遷移できる（UIKit→SiwftUI遷移）
  * ContentViewCに遷移する際、setNavigationBarHiddenをtrueにしてUIKitのナビゲーションバーは非表示にしてます。

```swift:ViewController
import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // タイトルを設定
        title = "画面A"
        
        // 画面Bに遷移するボタンを追加
        let navigateButton = UIButton(type: .system)
        navigateButton.setTitle("画面Bへ遷移", for: .normal)
        navigateButton.translatesAutoresizingMaskIntoConstraints = false
        navigateButton.addTarget(self, action: #selector(navigateToScreenB), for: .touchUpInside)
        
        view.addSubview(navigateButton)
        
        // ボタンの位置を設定
        NSLayoutConstraint.activate([
            navigateButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            navigateButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    @objc private func navigateToScreenB() {
        let viewControllerB = ViewControllerB()
        navigationController?.pushViewController(viewControllerB, animated: true)
    }
}
```

```swift:ViewControllerB
import UIKit
import SwiftUI

class ViewControllerB: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 背景色を設定
        view.backgroundColor = .white
        
        // タイトルを設定
        title = "画面B"
        
        // 画面Cに遷移するボタンを追加
        let navigateButton = UIButton(type: .system)
        navigateButton.setTitle("画面C (SwiftUI)へ遷移", for: .normal)
        navigateButton.translatesAutoresizingMaskIntoConstraints = false
        navigateButton.addTarget(self, action: #selector(navigateToScreenC), for: .touchUpInside)
        
        view.addSubview(navigateButton)
        
        // ボタンの位置を設定
        NSLayoutConstraint.activate([
            navigateButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            navigateButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 画面が表示される前にナビゲーションバーを再表示
        navigationController?.setNavigationBarHidden(false, animated: animated)
        navigationItem.leftBarButtonItems = [UIBarButtonItem.makeSwiftUIBackButton(target: self, action: #selector(backTapped))]
    }
    
    @objc func backTapped() {
        navigationController?.popViewController(animated: true)
    }

    
    @objc private func navigateToScreenC() {
        // ContentViewCを直接読み込み、UIHostingControllerを作成
        let contentView = ContentViewC()
        let hostingController = UIHostingController(rootView: contentView)
        
        // タイトルを設定
        hostingController.title = "画面C"
        
        // UIKitのナビゲーションバーを非表示にする
        self.navigationController?.setNavigationBarHidden(true, animated: false)
        
        // 作成したHostingControllerに遷移
        navigationController?.pushViewController(hostingController, animated: true)
    }
}

extension UIBarButtonItem {
    static func makeSwiftUIBackButton(target: Any?, action: Selector) -> UIBarButtonItem {
        let button = UIButton(type: .system)
        
        var config = UIButton.Configuration.plain()
        config.image = UIImage(systemName: "chevron.left") // SwiftUI の戻るボタンと同じ
        config.imagePlacement = .leading
        config.baseForegroundColor = .gray
        config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: -4, bottom: 0, trailing: 0) // 位置調整

        button.configuration = config
        button.addTarget(target, action: action, for: .touchUpInside)

        return UIBarButtonItem(customView: button)
    }
}

```

```swift:ContentViewC
import SwiftUI

struct ContentViewC: View {
    @Environment(\.presentationMode) var presentationMode
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("画面C (SwiftUI)")
                    .font(.largeTitle)
                    .padding()
                
                Text("これはSwiftUIで作成された画面です")
                    .padding()
            }
            .navigationTitle("画面C")
            .navigationBarBackButtonHidden(true)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button(action: {
                        presentationMode.wrappedValue.dismiss()
                    }) {
                        HStack {
                            Image(systemName: "chevron.left") // UIKit の戻るボタンと同じ
                                .foregroundColor(.gray)
                        }
                    }
                }
            }
        }
    }
}
```

この解決策の対処方法は、UIKitの以下で左にずらしている部分です。

```swift
config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: -4, bottom: 0, trailing: 0)
```

`leftBarButtonItems`に設定するとSwiftUIのtoolbarの戻るボタンより右側に表示されてしまうのですが、それに対処しています。

ちなみにSwiftUIの方を`.padding(.leading, -8)`でずらすことはできるのですが、見た目上移動するのですがタップ範囲は変わってないという残念なことになります。

## 2025-03-22更新

上記方法は確かに見た目も合うようになるのですが、UIKitで出来ていた画面左端一杯までのタップ範囲が失われてしまうことがわかりました。

調べた限り残された手段は２つ（と思われる）

1. UIKitのNavigationBarをhiddenにせず、SwiftUIのView毎にHostingControllerを作って、UIKit側で画面遷移させる
1. SwiftUIのデフォルトのナビゲーションバーは非表示にして、自作のナビゲーションバーを表示する

* 1.はUIKitと同じ方法で画面遷移できるのでUIKit使い的にはやりやすいと思われます。ただSwiftUI側からUIKit側を呼べるように整備する必要があります。
* 2.はできるだけSwiftUIを使いたい場合に有効と思われますが、自作する分iOS(Apple)側の仕様変更に影響を受けやすいリスクがあります。また遷移アニメーションが画面全体がスライドする形になります。

以下は2をやる場合のサンプルコードです。

```swift
import SwiftUI

struct CustomNavigationBarView: View {
    var title: String
    var onBack: () -> Void

    var body: some View {
        HStack {
            // 左の戻るボタン
            Button(action: {
                onBack()
            }) {
                HStack(spacing: 0) {
                    Spacer().frame(width: 8)
                    Image(systemName: "chevron.backward")
                        .font(.system(size: 20, weight: .medium))
                        .offset(y: -1)
                }
                .foregroundColor(.gray)
                .frame(width: 44, height: 38, alignment: .leading)
                .contentShape(Rectangle())
            }

            Spacer()

            // タイトル（中央）
            Text(title)
                .font(.system(size: 17, weight: .semibold))
                .foregroundColor(.black)

            Spacer()

            // 右側スペース調整（戻るボタンとバランス取るための空き）
            Spacer()
                .frame(width: 44)
        }
        .frame(height: 38)
        .background(Color.white)
        .overlay(Divider(), alignment: .bottom)
    }
}
```

```swift
import SwiftUI

struct CustomNavigationContainer<Content: View>: View {
    let title: String
    let onBack: () -> Void
    let content: Content

    init(title: String, onBack: @escaping () -> Void, @ViewBuilder content: () -> Content) {
        self.title = title
        self.onBack = onBack
        self.content = content()
    }

    var body: some View {
        VStack(spacing: 0) {
            CustomNavigationBarView(title: title, onBack: onBack)
            content
            Spacer()
        }
        .toolbar(.hidden, for: .navigationBar)
    }
}
```

```swift
import SwiftUI

struct ContentViewC: View {
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        CustomNavigationContainer(title: "画面C", onBack: {
            presentationMode.wrappedValue.dismiss()
        }) {
            VStack {
                Text("これはSwiftUIで作成された画面です")
                    .padding()
            }
        }
    }
}

```

## 2025-03-23 おまけ

上記のSwiftUIのカスタムナビゲーションでLargeTitleみたいなスクロールすることで表示が変わる動きをさせたい場合、使うView側でVStackを使いたいところだが、ひとまずこれでできる。

```swift
import SwiftUI

struct CustomNavigationLargeTitleContainer<Content: View>: View {
    let title: String
    let largeTitleMode: Bool
    let onBack: () -> Void
    let content: Content

    @State private var scrollOffset: CGFloat = 0

    init(
        title: String,
        largeTitleMode: Bool = false,
        onBack: @escaping () -> Void,
        @ViewBuilder content: () -> Content
    ) {
        self.title = title
        self.onBack = onBack
        self.largeTitleMode = largeTitleMode
        self.content = content()
    }

    var body: some View {
        VStack(spacing: 0) {
            // 小さいタイトル（ナビバー）
            CustomNavigationBarView(title: title, onBack: onBack, scrollOffset: largeTitleMode ? scrollOffset : -999)

            // スクロールビュー
            ScrollView {
                VStack(spacing: 0) {
                    if largeTitleMode {
                        // 大きいタイトル（スクロールで消える）
                        Text(title)
                            .font(.system(size: 34))
                            .padding(.horizontal, 16)
                            .padding(.top, 8)
                            .padding(.bottom, 4)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .opacity(max(0, min(1, (scrollOffset - 0) / 52.666)))
                            .animation(.easeInOut(duration: 0.2), value: scrollOffset)
                    }
                    // スクロール位置を取得するための透明なView（背景として機能）
                    GeometryReader { geo in
                        Color.clear
                            .preference(key: ScrollOffsetKey.self, value: geo.frame(in: .named("scroll")).minY)
                    }
                    .frame(height: 0)

                    content
                        .padding(.top, 8)
                }
            }
            .coordinateSpace(name: "scroll") // スクロールの座標をこの名前で指定
            .onPreferenceChange(ScrollOffsetKey.self) { value in
                scrollOffset = value
            }

            Spacer()
        }
        .toolbar(.hidden, for: .navigationBar)
    }
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
```

```swift
import SwiftUI

struct CustomNavigationBarView: View {
    var title: String
    var onBack: () -> Void
    let scrollOffset: CGFloat

    var body: some View {
        HStack {
            Button(action: {
                onBack()
            }) {
                HStack(spacing: 0) {
                    Spacer().frame(width: 8)
                    Image(systemName: "chevron.backward")
                        .font(.system(size: 20, weight: .medium))
                        .offset(y: -1)
                }
                .foregroundColor(.gray)
                .frame(width: 44, height: 38, alignment: .leading)
                .contentShape(Rectangle())
            }

            Spacer()

            Text(title)
                .font(.headline)
                .foregroundColor(.black)
                .opacity(scrollOffset < 20 ? 1 : 0)
                .animation(.easeInOut(duration: 0.2), value: scrollOffset)

            Spacer()
            Spacer().frame(width: 44)
        }
        .frame(height: 38)
        .background(Color.white)
        .overlay(Divider(), alignment: .bottom)
    }
}
```

使う側
```swift
import SwiftUI

struct ContentViewC: View {
    @Environment(\.presentationMode) var presentationMode
    var navigateToScreenD: (() -> Void)? = nil

    var body: some View {
        CustomNavigationLargeTitleContainer(title: "長い画面タイトルC長い画面タイトルC長い画面タイトルC長い画面タイトルC", largeTitleMode: true, onBack: {
            presentationMode.wrappedValue.dismiss()
        }) {
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()
            Text("これはSwiftUIで作成された画面です")
                .padding()

            Button("画面D (SwiftUI)へ遷移") {
                navigateToScreenD?()
            }
            .padding()
            .background(Color.blue.opacity(0.1))
            .cornerRadius(8)
            .padding()
        }
    }
}
```

動きはこんな感じです。
![gem.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618958/f0d124ad-b6d5-4665-97bf-126226206bd5.gif)

## 2025-03-31 Dynamic Island対策

Dynamic Islandがある端末だと高さが合わないことがわかったので、`safeAreaInsets.top`で判定し-6px上にずらすと対応できます。

```swift
import SwiftUI

struct CustomNavigationBarView: View {
    var title: String
    var onBack: () -> Void
    let scrollOffset: CGFloat

    var body: some View {
        VStack(spacing: 0) {
            // 以下2行を追加
            GeometryReader { geo in
                let topInset = geo.safeAreaInsets.top
                HStack {
                    Button(action: {
                        onBack()
                    }) {
                        HStack(spacing: 0) {
                            Spacer().frame(width: 8)
                            Image(systemName: "chevron.backward")
                                .font(.system(size: 20, weight: .medium))
                                .offset(y: -1)
                        }
                        .foregroundColor(.gray)
                        .frame(width: 44, height: 38, alignment: .leading)
                        .contentShape(Rectangle())
                    }

                    Spacer()

                    Text(title)
                        .font(.headline)
                        .lineLimit(1)
                        .foregroundColor(.black)
                        .opacity(scrollOffset < 20 ? 1 : 0)
                        .animation(
                            .easeInOut(duration: 0.2), value: scrollOffset)

                    Spacer()
                    Spacer().frame(width: 44)
                }
                .frame(height: 44)
                .background(Color.white)
                .overlay(Divider(), alignment: .bottom)
                // NOTE: Dynamic Island対策
                .padding(.top, topInset >= 59 ? -6 : 0)
            }
        }
    }
}
```

参考: [Dynamic IslandがあるiPhone 14 ProなどでナビゲーションバーのminYとsafeAreaInsets.topが一致しなくなっている](https://kouki.hatenadiary.com/entry/2022/10/18/095736)