Greetings, traveler!
Many iOS projects are not fully SwiftUI applications. A common setup is to use SwiftUI for building screens while keeping UIKit responsible for navigation:
let viewController = UIHostingController(
rootView: DetailsView()
)
navigationController.pushViewController(
viewController,
animated: true
)This approach works well, especially in existing UIKit applications. You can introduce SwiftUI screen by screen without rebuilding the navigation layer at the same time.
There is one detail that becomes less obvious, though: the navigation bar.
Who should configure the navigation bar?
With a regular UIKit screen, the answer is simple. The view controller owns a UINavigationItem, so it can configure its title and bar button items directly.
navigationItem.title = "Details"
navigationItem.rightBarButtonItem = UIBarButtonItem(...)With SwiftUI inside a UIHostingController, the screen itself no longer has direct access to that navigation item.
This creates an awkward split.
The SwiftUI view owns the screen state and knows whether a Save button should be enabled, which title should be shown, or which actions are available. But the corresponding navigation bar configuration often ends up somewhere outside the view, usually in a coordinator or in the code that creates the hosting controller.
Apple’s navigation model is still based on this relationship. A UINavigationController manages its navigation bar and uses the UINavigationItem objects of its view controllers to populate that bar.
That is fine from UIKit’s point of view, but less convenient when most of the screen is already written in SwiftUI.
Drawing another navigation bar is tempting
One workaround is to hide the UIKit navigation bar and build something that looks like a navigation bar directly inside the SwiftUI hierarchy.
For example:
VStack(spacing: 0) {
HStack {
Button {
goBack()
} label: {
Image(systemName: "chevron.left")
}
Spacer()
Text("Details")
Spacer()
Button("Save") {
save()
}
}
content
}At first this can seem easier. Everything belongs to SwiftUI, state updates naturally, and there is no need to communicate with the hosting controller.
I do not like this approach for screens that are still part of a UIKit navigation stack.
The problem is that the custom header only looks like a navigation bar. It is no longer the navigation bar managed by UINavigationController.
That means your code starts taking responsibility for behavior that UIKit normally handles for you: placement relative to safe areas, navigation transitions, Back button behavior, appearance changes, title layout, accessibility details, and integration with system bar button items.
This becomes more noticeable as UIKit itself changes. In iOS 26, for example, navigation bars adopted the new glass appearance, bar button grouping behavior changed, and UINavigationItem gained additional APIs such as a native subtitle. Code using the real navigation bar gets those platform behaviors through UIKit. A custom SwiftUI replacement has to reproduce whichever parts matter to the application.
If UIKit already owns navigation, I would rather keep using its navigation bar.
The missing piece is a convenient way for the SwiftUI screen to configure it.
Bridging the SwiftUI screen to its hosting controller
I created NavBarBridge for this case.
The idea is intentionally narrow. UIKit continues to own the navigation stack. The SwiftUI screen only gets a way to configure the UINavigationItem of the UIHostingController that contains it.
Instead of creating a normal hosting controller:
let viewController = UIHostingController(
rootView: DetailsView()
)NavBarBridge provides hostedWithBridge():
let viewController = DetailsView().hostedWithBridge()
navigationController.pushViewController(
viewController,
animated: true
)This installs the connection between the SwiftUI hierarchy and its hosting controller. Creating a regular UIHostingController does not install that bridge, so uiKitNavigationBar is meant to be used with hostedWithBridge().
UIKit still owns the controller. Nothing changes in the navigation architecture.
Configuring the bar inside SwiftUI
Once the screen is hosted through the bridge, the navigation bar can be configured where the rest of the screen is defined:
struct DetailsView: View {
@State private var canSave = false
var body: some View {
Form {
Toggle(
"Enable saving",
isOn: $canSave
)
}
.uiKitNavigationBar(
title: "Details",
subtitle: "UIKit navigation, SwiftUI content",
trailing: [
.systemImage(
"checkmark",
accessibilityLabel: "Save",
isEnabled: canSave
) { _ in
save()
}
]
)
}
private func save() {
// Save changes
}
}The useful part here is not only that the configuration moved into SwiftUI.
It also follows SwiftUI state.
When canSave changes, SwiftUI updates the modified view and NavBarBridge synchronizes the navigation configuration again. The underlying UIBarButtonItem receives the current enabled state. The modifier is specifically implemented to resynchronize its configuration during SwiftUI updates.
This removes one of the common problems with mixed UIKit and SwiftUI screens: manually forwarding every relevant state change back to the coordinator just to update the navigation bar.
How updates are synchronized
The navigation bar configuration is not applied only once when the hosting controller is created.
uiKitNavigationBar uses a small UIViewRepresentable internally. SwiftUI calls its updateUIView method when the surrounding view is updated, and NavBarBridge uses that callback to apply the latest configuration to the hosting controller’s UINavigationItem.
In simplified form, the flow looks like this:
SwiftUI state changes
↓
View body is reevaluated
↓
uiKitNavigationBar receives new values
↓
UIViewRepresentable.updateUIView
↓
UINavigationItem is updated
This is why values such as isEnabled can depend directly on SwiftUI state:
@State private var canSave = false
.uiKitNavigationBar(
trailing: [
.systemImage(
"checkmark",
accessibilityLabel: "Save",
isEnabled: canSave
) { _ in
save()
}
]
)There is no separate observer or manual synchronization with the coordinator. SwiftUI drives the update, while UIKit still owns and renders the actual navigation item.
Leading and trailing items
The modifier accepts arrays for both sides of the bar:
.uiKitNavigationBar(
title: "Details",
leading: [
.systemItem(
.close,
accessibilityLabel: "Close"
) { context in
context.viewController.dismiss(
animated: true
)
}
],
trailing: [
.systemImage(
"checkmark",
accessibilityLabel: "Save"
) { _ in
save()
},
.systemImage(
"ellipsis",
accessibilityLabel: "More"
) { _ in
showMore()
}
]
)NavBarBridge currently supports text items, custom images, SF Symbols, and UIKit system items. Multiple leading and trailing items can be provided.
There is one UIKit detail worth keeping in mind. Leading items use UINavigationItem.leftBarButtonItems semantics. Setting them can replace the system Back button unless the navigation item is configured to supplement it. NavBarBridge keeps UIKit’s behavior rather than inventing different semantics for SwiftUI.
Actions can still access UIKit
Sometimes a navigation action cannot stay entirely inside SwiftUI.
Presenting a UIActivityViewController is a good example. On iPad, the presentation may also need the tapped bar button item as its popover source.
For this reason, every action can receive a UIKitNavigationBarActionContext:
public struct UIKitNavigationBarActionContext {
public let item: UIBarButtonItem
public let viewController: UIViewController
}If you do not need it, simply ignore it:
.systemImage(
"checkmark",
accessibilityLabel: "Save"
) { _ in
save()
}When UIKit access is useful, the same API provides the hosting controller and the actual bar button item:
.systemItem(
.action,
accessibilityLabel: "Share"
) { context in
let controller = UIActivityViewController(
activityItems: [url],
applicationActivities: nil
)
if #available(iOS 16.0, *) {
controller
.popoverPresentationController?
.sourceItem = context.item
} else {
controller
.popoverPresentationController?
.barButtonItem = context.item
}
context.viewController.present(
controller,
animated: true
)
}This keeps UIKit available when the action actually needs it instead of forcing the entire navigation bar configuration back into UIKit.
Menus
Navigation items can also present a regular UIKit UIMenu.
.systemImage(
"ellipsis",
accessibilityLabel: "More",
menu: UIMenu(children: [
UIAction(
title: "Rename",
image: UIImage(systemName: "pencil")
) { _ in
rename()
},
UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { _ in
delete()
}
])
)Menu overloads are available for title, image, SF Symbol, and system item variants. The resulting control is still a real UIBarButtonItem using UIKit’s menu support.
Titles and subtitles
The basic modifier accepts both a title and a subtitle:
.uiKitNavigationBar(
title: "Profile",
subtitle: "Personal information"
)On iOS 26 and later, NavBarBridge uses the native UINavigationItem.subtitle.
Earlier versions do not have that API, so the library creates a custom titleView for the title and subtitle instead. The availability handling stays inside the bridge rather than spreading through the SwiftUI screen.
For cases where text is not enough, there is also a titleView overload:
.uiKitNavigationBar(
titleView: customTitleView
)This installs the supplied UIKit view directly as UINavigationItem.titleView.
iOS 26 bar button appearance
NavBarBridge also exposes some of the newer UIKit bar button configuration.
An item can be marked as prominent:
.systemImage(
"checkmark",
accessibilityLabel: "Save",
tintColor: .systemGreen,
isProminent: true
) { _ in
save()
}It can also control whether its background is shared with adjacent items:
.systemImage(
"checkmark",
accessibilityLabel: "Save",
isProminent: true,
sharesBackground: false
) { _ in
save()
}These options are applied on iOS 26 and later and ignored on earlier versions. UIKit still creates and renders the buttons, including its current navigation bar appearance and grouping rules.
UIKit navigation with SwiftUI ownership of screen configuration
This approach does not try to make UIKit navigation behave like SwiftUI navigation.
There is still a UINavigationController. It still owns the navigation stack. Each SwiftUI screen still lives inside a UIHostingController, and UIKit still renders the actual navigation bar.
The bridge changes where the configuration is written.
A screen that knows it should display “Save”, knows whether that action is enabled, and owns the corresponding behavior can describe that navigation item next to the rest of its SwiftUI UI.
For projects that already use SwiftUI views inside UIKit navigation, this keeps the existing navigation architecture while removing some of the friction around UINavigationItem.
NavBarBridge supports iOS 14 and later and is available as a Swift Package.
