UINavigationController has a delay rendering a UIHostingController's .navigationBarTitle(), .searchable(), and .toolbar()

It seems UINavigationControllers do not play nicely with UIHostingController.

When presenting a UIHostingController, the UINavigationBar does not update with the nav bar title until after the SwiftUI view has finished appearing on screen.

This only happens the first time the hosting controller loads its view. If you were to store the hosting controller somewhere, then try to present it a second time, everything works as expected.

Feedback: FB13287789

Pretty easy to reproduce:

Scene Delegate:

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        let navigationController = UINavigationController()
        let contentView = RootView(navController: navigationController) { navController in
            navController.pushViewController(UIHostingController(rootView: SearchView()), animated: true)
        }
        let firstController = UIHostingController(rootView: contentView)
        navigationController.setViewControllers([firstController], animated: false)
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()
    }

    func sceneDidDisconnect(_ scene: UIScene) {}

    func sceneDidBecomeActive(_ scene: UIScene) {}

    func sceneWillResignActive(_ scene: UIScene) {}

    func sceneWillEnterForeground(_ scene: UIScene) {}

    func sceneDidEnterBackground(_ scene: UIScene) {}
}

SwiftUI Views:

import UIKit
import SwiftUI

struct RootView: View {
    var navController: UINavigationController
    var didTapButton: (UINavigationController) -> ()

    var body: some View {
        Button {
            didTapButton(navController)
        } label: {
            Text("Tap this to show broken search bar animation")
        }
        .navigationTitle("First Page")
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct SearchView: View {
    let items: [Int] = {
        (0...100).map {$0}
    }()

    @State var searchText: String = ""

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text("\(item)")
            }
        }
        .listStyle(.plain)
        .navigationTitle("Search Page")
        .navigationBarTitleDisplayMode(.inline)
        .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search")
    }
}

Replies

I found a somewhat hacky workaround for this.

You can "pre-render" the navigation bar customization so that it does not appear to pop in.

This is not ideal though. Hopefully someone at apple fixes it. As it pretty much makes programmatic navigation in a mixed codebase impossible.

let hostingController = UIHostingController(rootView: MyViewWithASearchBar())
hostingController.title = "My search page title"

// pre-render the search bar
let searchController = UISearchController()
if #available(iOS 16.0, *) {
    hostingController.navigationItem.preferredSearchBarPlacement = .stacked
}

hostingController.navigationItem.searchController = searchController
hostingController.navigationItem.hidesSearchBarWhenScrolling = false
navigationController.pushViewController(hostingController, animated: true)