はじめに
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のナビゲーションバーは非表示にしてます。
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)
}
}
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)
}
}
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の以下で左にずらしている部分です。
config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: -4, bottom: 0, trailing: 0)
leftBarButtonItems
に設定するとSwiftUIのtoolbarの戻るボタンより右側に表示されてしまうのですが、それに対処しています。
ちなみにSwiftUIの方を.padding(.leading, -8)
でずらすことはできるのですが、見た目上移動するのですがタップ範囲は変わってないという残念なことになります。
2025-03-22更新
上記方法は確かに見た目も合うようになるのですが、UIKitで出来ていた画面左端一杯までのタップ範囲が失われてしまうことがわかりました。
調べた限り残された手段は2つ(と思われる)
- UIKitのNavigationBarをhiddenにせず、SwiftUIのView毎にHostingControllerを作って、UIKit側で画面遷移させる
- SwiftUIのデフォルトのナビゲーションバーは非表示にして、自作のナビゲーションバーを表示する
- 1.はUIKitと同じ方法で画面遷移できるのでUIKit使い的にはやりやすいと思われます。ただSwiftUI側からUIKit側を呼べるように整備する必要があります。
- 2.はできるだけSwiftUIを使いたい場合に有効と思われますが、自作する分iOS(Apple)側の仕様変更に影響を受けやすいリスクがあります。また遷移アニメーションが画面全体がスライドする形になります。
以下は2をやる場合のサンプルコードです。
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)
}
}
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)
}
}
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を使いたいところだが、ひとまずこれでできる。
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()
}
}
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)
}
}
使う側
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()
}
}
}
2025-03-31 Dynamic Island対策
Dynamic Islandがある端末だと高さが合わないことがわかったので、safeAreaInsets.top
で判定し-6px上にずらすと対応できます。
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が一致しなくなっている