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.
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.
struct ListViewWithNewAPI: View {
@State var selectedString: String? = nil
var body: some View {
let details = ["foo", "bar", "baz"]
List(details, id: \.self, selection: $selectedString) { detail in
NavigationLink {
Text("This is a detailed page for \(detail)")
.navigationTitle("Detail page")
} label: {
Text(detail)
}
}
.navigationTitle("List page")
.navigationBarTitleDisplayMode(.inline)
}
}
A navigation model to use in SwiftUI
class NavigationModel {
var navigationController: UINavigationController?
func push<V: View>(@ViewBuilder _ view: () -> V) {
let vc = UIHostingController(rootView: view())
navigationController?.pushViewController(vc, animated: true)
}
}
Add the navigationController to the model and feed the model to your view
// property of ViewController
private let navigationModel = NavigationModel()
@objc
private func tapButton2() {
navigationModel.navigationController = navigationController
let rootView = ListViewNavigation(navigationModel: navigationModel)
let hc = UIHostingController(rootView: rootView)
navigationController?.pushViewController(hc, animated: true)
}
This is how you use the navigation model
struct ListViewNavigation: View {
let navigationModel: NavigationModel
let details = ["foo", "bar", "baz"]
var body: some View {
List(details, id: \.self) { detail in
Button(action: { navigateTo(detail) }) {
Text("Go to \(detail)")
}
}
}
func navigateTo(_ detail: String) {
navigationModel.push {
Text("This is a detail page for \(detail)")
}
}
}