ricocrescenzio95/managednavigation
SwiftUI's `NavigationStack` and `NavigationPath` give you the building blocks for programmatic navigation, but they leave significant gaps when building real apps:
Features
- Typed navigation path — inspect any destination by index, type-check, cast, iterate
- Push single or multiple destinations in one call
- Pop to type —
popTo(HomeDestination.self)pops to the last occurrence - Pop to first —
popToFirst(HomeDestination.self)pops to the first occurrence - Pop by predicate —
popTo(where: { $0.destination is DetailsDestination })for custom logic - Pop by index —
popTo(at: 2)with optional type safety viapopTo(HomeDestination.self, at: 2) - Replace in place —
replace(ProfileDestination(), at: 1)swaps a destination while preserving the rest of the stack - Modal presentations — sheets and full-screen covers driven by the same path, with nested presentation support
- Disable animations —
withoutAnimation { }for instant navigation (state restoration, deep links) - State persistence —
Codablerepresentation that preserves type information, encode/decode with any encoder - Environment-based proxy — child views navigate via
@Environment(\.navigator)without coupling to the manager
Installation
ManagedNavigation can be installed using Swift Package Manager.
- In Xcode open File/Swift Packages/Add Package Dependency... menu.
- Copy and paste the package URL:
https://github.com/ricocrescenzio95/ManagedNavigationFor more details refer to Adding Package Dependencies to Your App documentation.
Usage
Navigation Stack
Use ManagedNavigationStack to wrap SwiftUI's NavigationStack with a typed, programmatic path:
@State var manager = NavigationManager()
ManagedNavigationStack(manager: $manager) {
Button("Go to Details") {
manager.push(DetailsDestination(id: "abc"))
}
.navigationDestination(for: DetailsDestination.self) { destination in
DetailsView(id: destination.id)
}
}Pop Operations
This is where ManagedNavigation shines — operations that SwiftUI simply doesn't offer:
// Pop to the last occurrence of a type
manager.popTo(HomeDestination.self)
// Pop to the first occurrence
manager.popToFirst(HomeDestination.self)
// Pop with a custom predicate
manager.popTo(where: { context in
context.destination is DetailsDestination && context.index > 0
})
// Pop to a specific index (with type safety)
manager.popTo(HomeDestination.self, at: 2)
// Inspect the stack at any time
for (i, destination) in manager.path.enumerated() {
print("\(i): \(destination.navigationID)")
}Replace Operations
Swap a destination at a specific index while keeping the rest of the stack intact:
// Stack: [Home, Details, Settings]
manager.replace(ProfileDestination(), at: 1)
// Stack: [Home, Profile, Settings]Modal Presentations
Use ManagedPresentation to manage sheets and full-screen covers with the same path-driven approach — no more juggling separate @State booleans:
@State var manager = NavigationManager()
ManagedPresentation(manager: $manager) {
Button("Open Settings") {
manager.push(SettingsDestination())
}
.sheet(for: SettingsDestination.self) { _ in
SettingsView()
}
.fullScreenCover(for: AccountDestination.self) { _ in
AccountView()
}
}Presentations nest automatically. If SettingsView registers its own .sheet(for:), pushing the corresponding destination presents it on top:
// Push a chain of modals in one call
manager.push([
SettingsDestination(),
NotificationsDestination(),
])
// Result: Settings sheet appears, then Notifications sheet on topDestinations
Define your destinations by conforming to NavigationDestination. Add Codable for state persistence:
struct DetailsDestination: NavigationDestination, Codable {
var id: String
}State Persistence
Save and restore the entire navigation state — something that requires significant boilerplate with vanilla NavigationPath:
// Save
if let codable = manager.codable {
let data = try JSONEncoder().encode(codable)
UserDefaults.standard.set(data, forKey: "savedPath")
}
// Restore
if let data = UserDefaults.standard.data(forKey: "savedPath"),
let codable = try? JSONDecoder().decode(
NavigationManager.CodableRepresentation.self, from: data
) {
manager = NavigationManager(codable)
}Disable Animations
Use withoutAnimation to perform navigation changes instantly — useful for state restoration or deep-link handling:
manager.withoutAnimation {
$0.push([
HomeDestination(),
DetailsDestination(id: "abc"),
])
}Child views can do the same through the navigator:
navigator?.withoutAnimation {
$0.popToRoot()
}Navigator
Child views can access navigation through the environment without any coupling to the manager:
struct ChildView: View {
@Environment(\.navigator) private var navigator
var body: some View {
Button("Pop to root") {
navigator?.popToRoot()
}
Button("Pop to Settings") {
navigator?.popTo(SettingsDestination.self)
}
}
}For advanced usages, please refer to the full Documentation.
Limitations
NavigationLinkis not supported. ManagedNavigation takes full control of the navigation path — usingNavigationLink(value:)will push items directly ontoNavigationPathand bypass the typed tracking, causing the internal state to go out of sync. Always usemanager.push()ornavigator?.push()instead.
- watchOS is not supported. The modal presentation system relies on
UIViewControllerRepresentable(iOS/tvOS/visionOS) orNSViewControllerRepresentable(macOS) to detect when a modal has finished presenting. watchOS has no equivalent API.
Documentation
Use Apple DocC generated documentation, from Xcode, Product > Build Documentation.
Found a bug or want new feature?
If you found a bug, you can open an issue as a bug here
Want a new feature? Open an issue here
You can also open your own PR and contribute to the project! Contributing
License
This software is provided under the MIT license
Package Metadata
Repository: ricocrescenzio95/managednavigation
Default branch: main
README: README.md