skip to Main Content

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.

enter image description here

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.

enter image description here

2

Answers


  1. 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:

    1. 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.

    2. 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)
        }
    }
    
    1. If you need to keep the UINavigationController (because of other UIKit-based code) AND you want programmatic/value-based navigation, you could do the following:

    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)")
            }
        }
    }
    
    Login or Signup to reply.
  2. 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 older NavigationLink(<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.

    @available(iOS 16.0, *)
    struct MyView: View {
        @Environment(.dismiss) private var dismiss
    
        var body: some View {
            NavigationStack {
                VStack {
                    Text("Blah blah blah...")
                }
                // SwiftUI navigation bar properties
                .navigationTitle("My View")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    // Emulate a back button to take us back to the UIKit controller
                    ToolbarItem(placement: .topBarLeading) {
                        Button {
                            dismiss()
                        } label: {
                            Image(systemName: "chevron.left")
                        }
    
                    }
                }
            }
            .toolbar(.hidden, for: .navigationBar) // Hide UIKit navigation bar
        }
    }
    

    I also needed a custom UIHostingController to re-display the UIKit navigation bar when transitioning from the SwiftUI view back to the UIKit one.

    @available(iOS 16.0, *)
    class MyViewHostingController: UIHostingController<MyView> {
        private var wasNavigationBarHidden: Bool?
        
        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override init(rootView: MyView) {
            super.init(rootView: rootView)
        }
        
        override func viewWillAppear(_ animated: Bool) {
            wasNavigationBarHidden = navigationController?.isNavigationBarHidden
        }
        
        override func viewWillDisappear(_ animated: Bool) {
            // Re-display the navigation bar
            if let wasNavigationBarHidden,
                let navigationController,
               navigationController.isNavigationBarHidden != wasNavigationBarHidden
            {
                navigationController.isNavigationBarHidden = wasNavigationBarHidden
            }
        }
    }
    
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search