A SwiftUI-first navigation core with a customizable top bar and a thin UIKit bridge. It hides UINavigationBar, drives your own top bar via environment, and exposes a tiny Navigator API for push/pop.
- iOS 15+
- Swift 5.10+ (supports Swift 6 strict concurrency)
Add SUINavigationFusion to your target via Xcode → Package Dependencies and link the product SUINavigationFusion.
import SwiftUI
import SUINavigationFusion
@main
struct DemoApp: App {
var body: some Scene {
WindowGroup {
NavigationShell(configuration: .defaultMaterial) { navigator in
InboxScreen()
.topNavigationBarTitle("Inbox")
.topNavigationBarSubtitle("All messages • 127")
.topNavigationBarLeading {
Image(systemName: "person.circle.fill").font(.title2)
}
.topNavigationBarTrailing(position: .secondary) {
Button { /* search */ } label: { Image(systemName: "magnifyingglass") }
}
.topNavigationBarTrailingPrimary {
Button { navigator.push(ComposeScreen(), disableBackGesture: true) } label: {
Image(systemName: "square.and.pencil")
}
}
}
}
}
}struct InboxScreen: View {
@EnvironmentObject private var navigator: Navigator
var body: some View {
List(sampleThreads) { thread in
Button {
navigator.push(ThreadScreen(thread: thread)) // animated by default
} label: { Text(thread.title) }
}
.topNavigationBarTitle("Inbox")
}
}
struct ThreadScreen: View {
@EnvironmentObject private var navigator: Navigator
let thread: ThreadModel
var body: some View {
ScrollView { /* content */ }
.topNavigationBarTitle(thread.title)
.topNavigationBarTrailingPrimary {
Button("Close") { navigator.pop() }
}
}
}push(_:animated:disableBackGesture:transition:)push(route:animated:disableBackGesture:transition:)(route must beNavigationPathItem)pop()/popNonAnimated()popToRoot(animated:)pop(levels:animated:)clearCachedStack()
Navigator.push(route:) requires a typed destination registry installed by one of:
TypedNavigationShell(typed routing only)PathRestorableNavigationShell/RestorableNavigationShell(typed routing + persistence)
Feature modules can decouple from a concrete stack by exporting a NavigationDestinations bundle:
// Feature module
public struct ThreadRoute: NavigationPathItem {
public static let destinationKey: NavigationDestinationKey = "com.myapp.thread"
public let id: String
}
public enum ThreadFeatureNavigation {
public static let destinations = NavigationDestinations { registry in
// Uses `ThreadRoute.destinationKey` as the persisted key.
registry.register(ThreadRoute.self) { route in
ThreadScreen(id: route.id)
}
}
}Compose bundles at the app root:
let destinations = ThreadFeatureNavigation.destinations
.merging(SettingsFeatureNavigation.destinations)
TypedNavigationShell(
destinations: destinations,
root: { _ in InboxScreen() }
)Navigator.push(_ view:) accepts arbitrary SwiftUI views, which are not serializable. If you want navigation stack
restoration (and/or path-driven navigation), push a serializable route payload via navigator.push(route:).
Route payloads should conform to NavigationPathItem so they have a stable persisted destination key.
- Use
RestorableNavigationShell(Option 3) for a single route type (usually an enum). - Use
PathRestorableNavigationShell(Option 4) for a modular, registry-driven setup (NavigationPath-like).
Only navigator.push(route:) participates in restoration. navigator.push(_ view:) is treated as transient.
- Use explicit stable destination keys. Keys are persisted, so avoid type-derived keys for long-lived data.
- Option 3: pass
key:explicitly. - Option 4: register with explicit per-destination keys (e.g.
"thread","settings"). .type(...)exists for convenience and applies best-effort normalization, but it remains refactor-sensitive.
- Option 3: pass
- Support renames with
aliases:. If you rename a key, keep the old key inaliases:so existing persisted stacks still restore. - Make
id:scene-unique for multi-window apps. Each window/scene needs its own persisted stack id; otherwise multiple scenes overwrite each other in the store.- Use
idScope: .scenefor automatic per-scene scoping (recommended).
- Use
- Treat your route payload as a persisted schema. Avoid breaking
Codablechanges, or keep backward-compatible decoding (versioning/migrations).- If you need stable date/key strategies, pass custom
encoder:/decoder:into the restorable shell.
- If you need stable date/key strategies, pass custom
import SUINavigationFusion
enum AppRoute: NavigationPathItem {
static let destinationKey: NavigationDestinationKey = "com.myapp.mainRoute"
case thread(id: String)
case settings
}
RestorableNavigationShell<AppRoute>(
id: "mainStack",
key: AppRoute.destinationKey,
configuration: .defaultMaterial,
additionalDestinations: ThreadFeatureNavigation.destinations, // optional
root: { _ in InboxScreen() },
destination: { route in
switch route {
case .thread(let id): ThreadScreen(id: id)
case .settings: SettingsScreen()
}
}
)Push a route:
navigator.push(route: AppRoute.thread(id: "123"))import SUINavigationFusion
struct ThreadRoute: NavigationPathItem {
static let destinationKey: NavigationDestinationKey = "com.myapp.thread"
let id: String
}
struct SettingsRoute: NavigationPathItem {
static let destinationKey: NavigationDestinationKey = "com.myapp.settings"
init() {}
}
PathRestorableNavigationShell(
id: "mainStack",
destinations: ThreadFeatureNavigation.destinations
.merging(SettingsFeatureNavigation.destinations),
root: { _ in InboxScreen() }
)destinations is applied once to register all destinations for this stack by mutating the registry via
registry.register(...) (avoid side effects).
Push a payload type:
navigator.push(route: ThreadRoute(id: "123"))- The route payload (
Codable) for everypush(route:). disableBackGestureflag per entry.
- SwiftUI local state (
@State, scroll position, focus, etc.). Reconstruct those from your route payload if needed. - Top bar titles / items (they are derived from each screen’s SwiftUI preferences).
You can mix push(route:) and push(_ view:), but restoration can only rebuild the prefix of the stack that is
fully route-backed. A transient push(_ view:) above root truncates the persisted snapshot for everything above it.
Call navigator.clearCachedStack() to remove the persisted snapshot for the current restorable shell.
If your app supports multiple windows/scenes, use idScope: .scene to automatically scope snapshots per scene/window:
RestorableNavigationShell<AppRoute>(
id: "catalog",
idScope: .scene,
key: "com.myapp.catalogRoute",
root: { _ in CatalogRootScreen() },
destination: { route in
/* ... */
}
)Use TopNavigationBarConfiguration to style the bar globally for a navigation stack.
let config = TopNavigationBarConfiguration(
backgroundMaterial: .regular, // or use `backgroundColor:`
scrollDependentBackgroundOpacity: true, // background becomes opaque after scroll threshold
dividerColor: Color.gray.opacity(0.35),
titleFont: .title3, // nil → system default
titleFontColor: nil,
subtitleFont: .footnote,
subtitleFontColor: .secondary,
titleFontWeight: .semibold,
subtitleFontWeight: nil,
titleStackSpacing: 2,
tintColor: nil, // nil → inherit SwiftUI tint (recommended default)
backButtonIcon: .init(name: "custom_back", bundle: .main) // optional, defaults to chevron
)Apply it when creating the shell:
NavigationShell(configuration: config) { navigator in
RootView()
}By default, SUINavigationFusion does not force a tint: if TopNavigationBarConfiguration.tintColor == nil,
the navigation stack uses the system default tint.
If you set TopNavigationBarConfiguration.tintColor, SUINavigationFusion applies it as a SwiftUI .tint(...) for the
entire hosted view hierarchy and uses it for bar items (back button + leading/trailing content).
Precedence: configuration tintColor → system.
Note: since the bar is installed outside the screen subtree, per-screen tinting is intentionally not supported.
The bar background is controlled separately via TopNavigationBarConfiguration.backgroundMaterial / backgroundColor.
If you want an external router/coordinator to own navigation state (similar to SwiftUI’s NavigationStack(path:)),
bind a SUINavigationPath into a restorable shell using path:.
In this mode:
- The shell reconciles the UIKit stack to match the bound
SUINavigationPath. - Interactive swipe-back updates the bound path (UIKit stack is authoritative for gestures).
Navigator.push(_ view:)is not supported (the stack must remain route-backed / representable as a path).- While UIKit is transitioning (animated push/pop, interactive swipe-back, iOS 18+ zoom dismiss), reconciliation is intentionally deferred and path mutations are coalesced until the transition completes. This avoids UIKit re-entrancy issues that can otherwise corrupt the navigation stack.
@MainActor
final class AppRouter: ObservableObject {
@Published var path = SUINavigationPath()
func openThread(id: String) {
// You can build heterogeneous paths here (multiple route types).
try? path.append(route: ThreadRoute(id: id))
}
func goToRoot() {
path.clear()
}
}Bind it into a shell:
@StateObject var router = AppRouter()
PathRestorableNavigationShell(
id: "mainStack",
idScope: .scene,
path: $router.path,
destinations: ThreadFeatureNavigation.destinations,
root: { _ in InboxScreen() }
)Path-driven pushes/pops animate by default (SwiftUI-like).
To disable animations for a specific path update (e.g. deep link), wrap it in a transaction:
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
router.path = newPath
}SUINavigationFusion supports the native iOS 18+ zoom navigation transition (the same system transition used by
Photos-style UIs). Under the hood, this is implemented via UIKit by configuring the destination view controller’s
preferredTransition.
- Mark the source view (e.g. a thumbnail) on the current screen:
Thumbnail(photo: photo)
.suinavZoomSource(id: photo.id)- (Optional but recommended) mark the hero view on the destination screen:
Image(uiImage: photo.image)
.resizable()
.scaledToFit()
.suinavZoomDestination(id: photo.id)- Request the transition when pushing:
navigator.push(
route: PhotoRoute(id: photo.id),
transition: .zoom(id: photo.id)
)UIKit calls the zoom transition’s sourceViewProvider closure both when pushing and when popping.
If your zoomed screen can change which “item” it represents without leaving the screen (for example, paging
between photos inside a single detail controller), the correct thumbnail to zoom back to can change over time.
To support this, apply .suinavZoomDismissTo(...) on the zoomed screen and update it whenever the current item changes:
struct PhotoDetail: View {
@State var currentID: Photo.ID
var body: some View {
VStack {
// The hero element for the currently displayed photo.
PhotoHeroView(id: currentID)
.suinavZoomDestination(id: currentID)
// …
}
// Tell UIKit which thumbnail to zoom back to when dismissing.
.suinavZoomDismissTo(id: currentID)
}
}If you need separate source/destination ids, use:
.suinavZoomDismissTo(sourceID: currentID, destinationID: currentID)In path-driven navigation, navigator.push(route:transition:) works the same way: it mutates the bound path and the
shell applies the requested transition when reconciling the UIKit stack.
If your router mutates the path directly, you can request a transition via a transaction (iOS 17+):
withSUINavigationTransition(.zoom(id: photo.id)) {
router.path.append(route: PhotoRoute(id: photo.id))
}You can provide a default transition while registering a destination. This is used when no explicit transition is requested at the call site:
registry.register(PhotoRoute.self, defaultTransition: { route in
.zoom(id: route.id)
}) { route in
PhotoDetailScreen(id: route.id)
}- Zoom transitions require iOS 18+ at runtime. On older OS versions, the library falls back to the standard push/pop.
- If multiple views register the same id at the same time, the last writer wins.
- If the source view is not available when popping back (e.g. scrolled offscreen), UIKit may fall back to a default animation.
- If you push a screen with
disableBackGesture: true, SUINavigationFusion always disables zoom’s interactive dismiss gestures for that push, regardless of your transition policy. This keeps the library’s “no interactive back” contract consistent across edge-swipe back and zoom dismiss.
iOS 18+ zoom transitions are continuously interactive and can be interrupted at any time. UIKit may run view controller callbacks multiple times and may convert an interrupted push into a pop within the same run loop.
To keep navigation stable:
- Apple recommends not blocking pushes/pops just because a transition is running (UIKit handles continuous transitions).
However, in SUINavigationFusion’s path-driven mode, UIKit mutations are triggered by SwiftUI reconciliation.
We observed that attempting to reconcile (push/pop/setViewControllers) while UIKit is still transitioning can corrupt
the navigation stack and break animations (especially for iOS 18+ zoom interactive dismiss). For correctness, the
library serializes path-driven mutations by queueing them and applying once the transition finishes
(
didShow/ transition-coordinator completion). - Keep any temporary transition state one-shot and self-contained, and always clean it up in
UINavigationControllerDelegate.navigationController(_:didShow:animated:)or a transition-coordinator completion. - Do not publish SwiftUI state synchronously from
UIViewControllerRepresentable.updateUIViewController/View.body. If UIKit state needs to flow back into SwiftUI, defer it to the next run loop tick.
UIKit’s zoom transitions can add interactive dismiss gestures (e.g. swipe-down/pinch) that are separate from
edge-swipe back. Use SUINavigationZoomInteractiveDismissPolicy to control whether those gestures are allowed to begin.
Common examples:
// Disable zoom interactive dismiss entirely.
transition: .zoom(id: photo.id, interactiveDismissPolicy: .disabled)
// Allow dismiss only when the gesture starts inside the hero element.
transition: .zoom(id: photo.id, interactiveDismissPolicy: .onlyFromDestinationAnchor())
// Compose multiple rules.
transition: .zoom(
id: photo.id,
interactiveDismissPolicy: .onlyFromDestinationAnchor()
.and(.downwardSwipe(minimumVelocityY: 200))
)The alignment rect controls which area of the destination screen the source view should zoom into. For complex detail screens, providing an alignment rect often improves visual quality (reduces “ghosting” and jumps).
SUINavigationFusion exposes this via SUINavigationZoomAlignmentRectPolicy.
// Use the destination anchor (the view marked with `.suinavZoomDestination(id:)`).
transition: .zoom(id: photo.id, alignmentRectPolicy: .destinationAnchor())
// Inset the hero rect (in the destination view controller’s coordinate space).
transition: .zoom(
id: photo.id,
alignmentRectPolicy: .destinationAnchor(inset: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
)
// Full control (example: safe-area bounds).
transition: .zoom(id: photo.id, alignmentRectPolicy: .custom { $0.zoomedSafeAreaBounds })UIKit can apply a tint and/or a blur behind the zoomed controller during the zoom transition.
SUINavigationFusion exposes this via:
dimmingColor: Color?(niluses UIKit default)dimmingVisualEffect: SUINavigationZoomDimmingVisualEffect?(niluses UIKit default, typically “no blur”)
transition: .zoom(
id: photo.id,
dimmingColor: .black.opacity(0.35),
dimmingVisualEffect: .blur(style: .systemUltraThinMaterial)
)Use plain strings: .topNavigationBarTitle("Title"), .topNavigationBarSubtitle("Subtitle") or provide fully styled Text (overrides config fonts/colors):
.topNavigationBarTitle { Text("Title").kerning(0.5) }
.topNavigationBarSubtitle { Text("Details").foregroundStyle(.secondary) }In production apps, it’s common to keep SUINavigationFusion “clean” and build a tiny wrapper module in your app that:
- Pins a default
TopNavigationBarConfiguration(your brand styles). - Exposes convenience APIs that accept your design-system components (e.g. a standard toolbar button view).
- Optionally injects your design-system theme/context once, at the root.
Example wrapper (rename AppDesignSystem / ToolbarButton / colors / fonts to match your project):
import SwiftUI
import SUINavigationFusion
import AppDesignSystem
// periphery:ignore
public extension View {
// MARK: - App-style top bar wrappers
func appNavigationBarTitle(_ title: String) -> some View {
topNavigationBarTitle(title)
}
func appNavigationBarTitle(_ text: @escaping () -> Text) -> some View {
topNavigationBarTitle(text)
}
func appNavigationBarSubtitle(_ subtitle: String) -> some View {
topNavigationBarSubtitle(subtitle)
}
func appNavigationBarSubtitle(_ text: @escaping () -> Text) -> some View {
topNavigationBarSubtitle(text)
}
func appNavigationBarHidesBackButton(_ hides: Bool = true) -> some View {
topNavigationBarHidesBackButton(hides)
}
func appNavigationBarLeading(
id: AnyHashable? = nil,
updateKey: AnyHashable? = nil,
@ViewBuilder _ content: () -> AppDesignSystem.ToolbarButton
) -> some View {
topNavigationBarLeading(id: id, updateKey: updateKey, content)
}
func appNavigationBarTrailing(
id: AnyHashable? = nil,
updateKey: AnyHashable? = nil,
position: TrailingContentPosition = .primary,
@ViewBuilder _ content: () -> AppDesignSystem.ToolbarButton
) -> some View {
topNavigationBarTrailing(id: id, updateKey: updateKey, position: position, content)
}
func appNavigationBarPrincipalView<Content: View>(
id: AnyHashable? = nil,
updateKey: AnyHashable? = nil,
@ViewBuilder _ content: () -> Content
) -> some View {
topNavigationBarPrincipalView(id: id, updateKey: updateKey, content)
}
func appToolbarVisibility(
_ visibility: TopNavigationBarVisibility,
for section: TopNavigationBarSection
) -> some View {
topNavigationBarVisibility(visibility, for: section)
}
}
extension TopNavigationBarConfiguration {
static let appNavigationBarConfiguration: TopNavigationBarConfiguration = {
TopNavigationBarConfiguration(
backgroundColor: AppDesignSystem.Colors.backgroundPrimary,
scrollDependentBackgroundOpacity: false,
dividerColor: AppDesignSystem.Colors.separator,
titleFont: AppDesignSystem.Fonts.navigationTitle,
titleFontColor: AppDesignSystem.Colors.textPrimary,
subtitleFont: AppDesignSystem.Fonts.navigationSubtitle,
subtitleFontColor: AppDesignSystem.Colors.textSecondary,
titleStackSpacing: nil, // use library default
tintColor: AppDesignSystem.Colors.accent
)
}()
}
public struct AppNavigationShell<Root: View>: View {
private let navigator: Navigator?
private let rootBuilder: (Navigator) -> Root
public init(@ViewBuilder root: @escaping (Navigator) -> Root) {
self.navigator = nil
self.rootBuilder = root
}
public init(navigator: Navigator, @ViewBuilder root: @escaping () -> Root) {
self.navigator = navigator
self.rootBuilder = { navigator in root() }
}
public var body: some View {
Group {
if let navigator {
NavigationShell(
navigator: navigator,
configuration: .appNavigationBarConfiguration,
root: { rootBuilder(navigator) }
)
} else {
NavigationShell(
configuration: .appNavigationBarConfiguration,
root: rootBuilder
)
}
}
.ignoresSafeArea(.all, edges: [.top, .bottom])
}
}.topNavigationBarLeading { AvatarView() } .topNavigationBarTrailing(position: .secondary) { Button { } label: { Image(systemName: "magnifyingglass") } } .topNavigationBarTrailingPrimary { Button { } label: { Image(systemName: "ellipsis.circle") } }
You can show/hide specific parts of the bar per screen:
// Hide the entire bar (and remove the safe-area inset).
.topNavigationBarVisibility(.hidden, for: .bar)
// Hide only specific regions.
.topNavigationBarVisibility(.hidden, for: .leading)
.topNavigationBarVisibility(.hidden, for: .principal)
.topNavigationBarVisibility(.hidden, for: .trailing)
.topNavigationBarVisibility(.hidden, for: .trailingPosition(.secondary))- Hidden automatically on the root screen.
- Per-screen control:
.topNavigationBarHidesBackButton(true) - Disable the interactive back swipe per push:
navigator.push(Screen(), disableBackGesture: true)
If scrollDependentBackgroundOpacity is true, emit content offset via PositionObservingViewPreferenceKey:
ScrollView {
content
.background(
GeometryReader { proxy in
Color.clear.preference(
key: PositionObservingViewPreferenceKey.self,
value: CGPoint(x: 0, y: proxy.frame(in: .named("scroll")).minY)
)
}
)
}
.coordinateSpace(name: "scroll")The bar will fade from translucent to opaque as you scroll.
SUINavigationFusion is a SwiftUI-first façade over a UIKit UINavigationController:
- It hosts each SwiftUI screen inside a
UIHostingControllermanaged by a customNCUINavigationController. - It keeps the system
UINavigationBarhidden and renders its own SwiftUI top bar instead.
-
Navigator(imperative API)Navigatoris injected as anEnvironmentObjectinto every hosted screen.- It performs push/pop on the underlying UIKit stack.
- In path-driven mode (when a shell is created with
path:),Navigatorbecomes a façade that mutates the boundSUINavigationPathinstead of mutating UIKit directly.
-
Custom top bar (SwiftUI chrome)
- Screens describe their bar content using view modifiers (
.topNavigationBarTitle,.topNavigationBarLeading, etc.). - Those modifiers write
PreferenceKeys which are collected by a container modifier that renders the bar. - The bar is installed outside the screen subtree via
.safeAreaInset(edge: .top, ...), which is why per-screen tint overrides for bar items are intentionally not supported.
- Screens describe their bar content using view modifiers (
-
TopNavigationBarConfiguration(stack-wide styling)- Shells inject a shared configuration store into the environment.
- When
tintColoris non-nil, the library applies it as a SwiftUI.tint(...)to the hosted view hierarchy and uses it for bar items.
-
Typed routing (registry)
NavigationDestinationRegistrymaps{destinationKey, payloadType}to “decode + build SwiftUI view”.TypedNavigationShellinstalls the registry without persistence.PathRestorableNavigationShellinstalls the registry and also enables persistence/restoration.- Feature modules can stay decoupled by exporting
NavigationDestinationsbundles.
-
Persistence / restoration
- The persisted form of the route-backed stack is
SUINavigationPath(schemaVersion+elements). - The restoration engine rebuilds view controllers from
{key, payload}via the registry. - The saved snapshot is kept authoritative by observing
UINavigationControllerDelegate.didShow(covers swipe-back).
- The persisted form of the route-backed stack is
-
Path-driven navigation (external router-owned state)
- If a shell is created with
path:, the UIKit stack is reconciled to match the boundSUINavigationPath. - Simple diffs (
push/pop/popToPrefix) map to UIKit animated transitions; large diffs rebuild the stack. - Use SwiftUI transactions to control animation for path mutations:
- default: animate
- disable:
withTransaction(Transaction(disablesAnimations: true)) { ... }
- If a shell is created with
-
Zoom transitions (iOS 18+)
- For single-step pushes, the library can configure the destination view controller’s
preferredTransitionas a native.zoom(...)transition. - SwiftUI code provides UIKit anchor views via
.suinavZoomSource(id:)/.suinavZoomDestination(id:). - In path-driven mode, the transition request can be provided via
navigator.push(route:transition:)or via a transaction usingwithSUINavigationTransition(...)(iOS 17+).
- For single-step pushes, the library can configure the destination view controller’s
A compact sample showing titles, actions, push/pop, and scroll-aware background: SUINavigationCore-Sample → https://github.com/ivalx1s/SUINavigationCore-Sample