I have a UIKit app and I migrated some of my screens to SwiftUI using UIHostingController. I used to be able to re-use the same nav bar of UIKit. But after switching to NavigationStack API, I wasn’t able to replicate the same behavior.
Here’s a complete reproducible code.
import UIKit
import SwiftUI
struct ListView: View {
@State var selectedString: String? = nil
var body: some View {
let details = ["foo", "bar", "baz"]
// No need to wrap under NavigationView, otherwise will have double Nav Bar
// This will use the UIKit's nav bar
List {
ForEach(details, id: .self) { detail in
let destination = Text("This is a detailed page for (detail)")
.navigationTitle("Detail page")
NavigationLink(
detail,
destination: destination,
tag: detail,
selection: $selectedString)
}
}
.navigationTitle("List page")
}
}
struct ListViewWithNewAPI: View {
@State var selectedString: String? = nil
var body: some View {
let details = ["foo", "bar", "baz"]
NavigationStack {
List(details, id: .self, selection: $selectedString) { detail in
NavigationLink(detail, value: detail)
}
.navigationDestination(item: $selectedString) { detail in
Text("This is a detailed page for (detail)")
.navigationTitle("Detail page")
}
.navigationTitle("List page")
.navigationBarTitleDisplayMode(.inline)
}
}
}
class ViewController: UIViewController {
@objc
private func tapButton1() {
let listVC = UIHostingController(rootView: ListView())
navigationController?.pushViewController(listVC, animated: true)
}
@objc
private func tapButton2() {
let listVC = UIHostingController(rootView: ListViewWithNewAPI())
navigationController?.pushViewController(listVC, animated: true)
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let button1 = UIButton(frame: CGRect(x: 100, y: 100, width: 100, height: 100))
button1.backgroundColor = .green
button1.addTarget(self, action: #selector(tapButton1), for: .touchUpInside)
view.addSubview(button1)
let button2 = UIButton(frame: CGRect(x: 100, y: 300, width: 100, height: 100))
button2.backgroundColor = .red
button2.addTarget(self, action: #selector(tapButton2), for: .touchUpInside)
view.addSubview(button2)
navigationItem.title = "UIKit title"
}
}
The storyboard file is pretty much the initial project, except that I embedded the VC under a nav vc.
In the above code, ListView
is implemented using the deprecated NavigationView
, which works well with UIHostingController
. ListViewWithNewAPI
is the new implementation using the new NavigationStack
API, and I wasn’t able to replicate the original behavior.
Here’s a video comparing the 2 behaviors. Please use the sample code and play around, and see if we can achieve the original behavior using the new API.
2
Answers
NavigationStack replaces NavigationView. In your code you push the UIHostingController on a navigationController. The view in ListViewWithNewAPI however contains a NavigationStack. This means you have now have two levels of ‘navigation stacks’, leading to undesirable behaviour.
Here are three different options to resolve the issue:
One option is to get rid of the Navigation Controller in your story board. You cannot directly use a UIHostingController in a story board because it uses generics, but you can wrap it in a (non-UINavigationController) UIViewController. Or you can get rid of storyboards altogether and adopt the SwiftUI app life cycle. You can then use the code in ListViewWithNewAPI as-is.
You can keep using the NavigationLink(destination: label:), which is not deprecated and continues to work in combination with a UINavigationController. Unfortunately this means no programmatic or value-based navigation.
A navigation model to use in SwiftUI
Add the navigationController to the model and feed the model to your view
This is how you use the navigation model
I also encountered this double navigation bar issue (UIKit UINavigationController pushing a UIHostingController whose rootView had an embedded
NavigationStack
). For my use case I really wanted to keep programmatic.navigationDestination(<type>) {…}
NavigationStack
navigation, primarily as I needed to keep the destination views "lazy" (so olderNavigationLink(<destination>, <label>)
methods wouldn’t have been "lazy" unless I went for significant hackery).I also needed to keep the UIKit navigationController for the other non-SwiftUI portions of the app. So my solution was to hide the UIKit navbar within the SwiftUI view and then emulate the back button within the NavigationStack. The animations etc. aren’t seamless however it was fine for my needs.
I also needed a custom UIHostingController to re-display the UIKit navigation bar when transitioning from the SwiftUI view back to the UIKit one.