This project provides a lightweight Navigation Coordinator, using SwiftUI NavigationStack (available from iOS 16).
The current implementation covers 6 main transitions:
Stack Navigation:
push
— navigates forward to a new view.pop
— returns to the previous view.unwind
— performs a multi-level return.popToRoot
— returns to the root view.
Modal Presentation:
present
— displays a modal view, overlaying it on top of current content.dismiss
— closes the current modal view and returns to the underlying content.
- iOS 16.0+
- The implementation of the
unwind
transition may be of particular interest to those who have already attempted to create similar transitions in SwiftUI. - In addition to the specific task of multi-level return, the
unwind()
can also be used instead of the usualpop()
when it is necessary to pass data back to the previous screen. TheonUnwind()
call will always be made before theonAppear()
call.
Push
struct SomeView: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
Button("info") {
coordinator.push(.info)
}
}
}
Pop
struct SomeView: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
Button("back") {
coordinator.pop()
}
}
}
PopToRoot
struct SomeView: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
Button("login") {
coordinator.popToRoot()
}
}
}
Unwind
Use a unique identifier for your unwind segues. If a segue becomes no longer relevant, it will be automatically removed from the coordinator. Using `onUnwind()` modifier is completely safe, tested, and does not involve any memory leaks or unintended calls.// B View
// 🟦🟦🅰🟦🟦🟦🟦🟦🟦🅱️
struct B: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
Button("pop to A") {
coordinator.unwind(to: "identifier" /*, with: Any?*/)
}
}
}
// A View
// 🟦🟦🅰️
struct A: View {
var body: some View {
VStack {}
.onUnwind(segue: "identifier") /*{ Any? in }*/
}
}
onUnwind()
will always be called before onAppear()
.
Present
/*
[B]
[ ][ ][ ][ ][ ][A]
*/
struct A: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
Button("present") {
coordinator.present(.B)
}
}
}
Dismiss
/*
[B][ ][ ][ ][CL]
[ ][ ][ ][ ][ ][A]
*/
struct CL: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
Button("dismiss") {
coordinator.dismiss(/*to: "identifier" /*, with: Any?*/*/)
}
}
}
/*
[ ][ ][ ][ ][ ][A]
*/
struct A: View {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
var body: some View {
VStack {}
// Not necessary. Only if you need to capture an onDismiss event.
.onDismiss(segue: "identifier") /*{ Any? in }*/
}
}
Add NavigationCoordinator to your project.
Configure the App to start with `RootView` as the initial view.
import SwiftUI
struct RootView: View {
@ObservedObject private var coordinator: NavigationCoordinator<Screen>
private let root: Screen
internal init(_ root: Screen, withParent coordinator: NavigationCoordinator<Screen>? = nil) {
self.root = root
self.coordinator = NavigationCoordinator<Screen>()
self.coordinator.parent = coordinator
}
var body: some View {
NavigationStack(path: $coordinator.path) {
root.view
.navigationDestination(for: Screen.self) { screen in
screen.view
}
.sheet(item: $coordinator.modal) { screen in
RootView(screen, withParent: coordinator)
}
}
.environmentObject(coordinator)
}
}
import SwiftUI
@main
struct SomeApp: App {
var body: some Scene {
WindowGroup {
RootView(.login)
}
}
}
Configure the `NavigableScreen` for your project.
In the view property, I recommend avoiding direct View initialization. Instead, use your preferred Dependency Injection pattern, such as View Factory, to externally connect various dependencies to your ViewModel.
import SwiftUI
// Example
enum Screen {
case login
case movies
case detail(id: Int)
case info
/// Used to uniquely identify segues that either navigate back to a previous screen or dismiss a modal view.
static let toDetail = "toDetail"
static let toMovies = "toMovies"
}
extension Screen: NavigableScreen {
// You can set up DI in this property
@ViewBuilder
var view: some View {
switch self {
case .login:
viewFactory.makeLoginView()
case .movies:
viewFactory.makeMoviesView()
case .detail(let id):
viewFactory.makeDetailView(id)
case .info:
viewFactory.makeInfoView()
}
}
}
Finally, add the `RegisterSegueModifier` to your project.
To implement the onUnwind()
and onDismiss()
calls in your views, similar to how onAppear()
is used.
import SwiftUI
struct RegisterSegueModifier: ViewModifier {
@EnvironmentObject var coordinator: NavigationCoordinator<Screen>
let type: NavigationCoordinator<Screen>.Segue.SegueType
let identifier: String
let action: ((Any?) -> Void)?
func body(content: Content) -> some View {
content.onAppear {
coordinator.registerSegue(type, with: identifier, action: action)
}
}
}
extension View {
func onUnwind(segue identifier: String, perform action: ((Any?) -> Void)? = nil) -> some View {
modifier(RegisterSegueModifier(type: .unwind, identifier: identifier, action: action))
}
func onDismiss(segue identifier: String, perform action: ((Any?) -> Void)? = nil) -> some View {
modifier(RegisterSegueModifier(type: .dismiss, identifier: identifier, action: action))
}
}
Feel free to take it, modify it, and use it as you see fit.
I welcome any issues you find within the project. If you encounter bugs or have suggestions for improvements, please feel free to create an issue on the GitHub repository.
Apache License 2.0. See the LICENSE file for details.