Navigator
is a Router
library for SwiftUI.
It supports the implementation of routing that is independent between features in a modularized application by feature.
In an app composed of multiple Feature modules, we want to eliminate dependencies between Feature modules to facilitate development on a per-Feature basis.
However, there are cases where the destination screen is from a different Feature module.
In such situations, we want to manage screen transitions without needing to know the specific type of the destination screen.
Navigator
is distributed via Swift Package Manager.
Please add the following to the dependencies in Package.swift
.
.package(url: "https://github.com/taji-taji/Navigator", from: "0.0.1"),
First, wrap the initial screen of the screen transition in a RootView.
import SwiftUI
import AppFeature
// ⭐️ 1️⃣ - Import `Navigator` module
import Navigator
@main
struct NavigatorExampleApp: App {
var body: some Scene {
WindowGroup {
// ⭐️ 2️⃣ - Wrap ContentView with `RootView`
RootView(viewProvider: viewProvider) { // ⭐️ 3️⃣
ContentView()
}
}
}
}
⭐️ 3️⃣ The viewProvider
argument is a closure with the signature @ViewBuilder viewProvider: @escaping (any NavigationDestination) -> some View
. In this example, a method named viewProvider
that follows this signature is defined as follows:
import SwiftUI
// ⭐️ 1️⃣ - Import `Navigator` module
import Navigator
// ⭐️ 2️⃣ - Define a method to use as an argument for `RootView`
@ViewBuilder
public func viewProvider(destination: any NavigationDestination) -> some View {
// ⭐️ 3️⃣
EmptyView()
}
This method (or closure) maps the abstracted destinations to their corresponding concrete View
types, so it should be defined in a module that depends on each feature, such as the app target module.
⭐️ 3️⃣ At this stage, since the destination has not been defined in each Feature module, you can return EmptyView() as a placeholder. (Alternatively, you can use fatalError() as well.)
Of course, you can also write the closure directly as an argument to RootView
.
Use Navigator
in each Feature module.
In the following example, we will use Navigator
with a screen named View1
inside a module called Feature1
.
import SwiftUI
// ⭐️ 1️⃣ - Import `Navigator` module
import Navigator
public struct View1: View, Navigatable /* ⭐️ 2️⃣ - Conform to the `Navigatable` protocol */ {
// ⭐️ 3️⃣ - Implement a `Destination` type that conforms to the `NavigationDestination` protocol.
// The `Destination` type abstractly represents the screens to which transitions occur from this screen.
// In this example, `view3` is a screen located in the `Feature2` module, not in the `Feature1` module.
// However, you do not need to know the specific type of `View` here.
// In other words, there is no need to depend on `Feature2`.
public enum Destination: NavigationDestination {
case view2
case view3(fromView: String)
}
// ⭐️ 4️⃣ - Implement a `navigator` property that conforms to the `Navigatable` protocol.
@EnvironmentObject public var navigator: Navigator
public init() {}
public var body: some View {
VStack {
Text("View1")
Button("to View2") {
// ⭐️ 5️⃣ - By conforming to the `Navigatable` protocol, you can use the `navigate(to:)` protocol method.
// Call the `navigate(to:)` method wherever you want to perform a screen transition.
// Here, since we want to transition to `view2`, we pass `Destination.view2` as the argument.
navigate(to: Destination.view2)
}
Button("to View3") {
// ✅ Since `navigate(to:)` is a wrapper around `navigator.navigate(to:)`, you can also directly use `navigator.navigate(to:)`.
navigator.navigate(to: Destination.view3(fromView: "View1"))
}
}
.navigationTitle("View1")
// ⭐️ 6️⃣ - Use the `navigatable(for:)` modifier on the `View` in the body.
// ⚠️ Without this, screen transitions will not be possible, so make sure not to forget it.
.navigatable(for: Destination.self)
}
}
Or use @Navigatable
macro.
By applying the @Navigatable
macro, the following can be omitted:
- You no longer need to explicitly declare conformance to the
Navigatable
protocol - You no longer need to explicitly declare the
navigator
property
import SwiftUI
import Navigator
// ⭐️ 1️⃣ - Attach `@Navigatable` macro to View
@Navigatable
public struct View1: View /* ⭐️ 2️⃣ - No need to write `Navigatable` protocol */ {
public enum Destination: NavigationDestination {
case view2
case view3(fromView: String)
}
// ⭐️ 3️⃣ - No need to write `navigator` property
public init() {}
public var body: some View {
VStack {
Text("View1")
Button("to View2") {
navigate(to: Destination.view2)
}
Button("to View3") {
navigate(to: Destination.view3(fromView: "View1"))
}
}
.navigationTitle("View1")
.navigatable(for: Destination.self)
}
}
Warning
A View
conforming to the Navigatable
protocol must have a RootView
at the top level of the view hierarchy.
This is because it internally uses an EnvironmentObject
injected by RootView
.
For the above reason, when using Preview, you need to wrap it in a RootView
.
Here is an example of a preview for View1
:
#Preview {
RootView(
viewProvider: { _ in
Text("Preview")
},
content: {
View1()
}
)
}
Complete the viewProperty
method defined in Step 1.
import SwiftUI
import Navigator
import Feature1
import Feature2
@ViewBuilder
public func viewProvider(destination: any NavigationDestination) -> some View {
switch destination {
// ⭐️ 1️⃣ - Return the `View` corresponding to the `View1.Destination` enum.
case let d as View1.Destination:
switch d {
case .view2:
View2()
case let .view3(fromView):
// ⭐️ 2️⃣ - `View3` is a type from the `Feature2` module.
View3(fromView: fromView)
}
default:
fatalError()
}
}
import SwiftUI
import Navigator
@Navigatable
public struct ContentView: View {
public enum Destination: NavigationDestination {
case view1
case view3(fromView: String)
}
public init() {}
public var body: some View {
List {
// ⭐️ 1️⃣ - Using `Navigator` with `NavigationLink` is straightforward:
// simply pass a `Destination` value to the `value` parameter,
// and screen transitions will occur when the `NavigationLink` is tapped.
NavigationLink("to View1", value: Destination.view1)
NavigationLink("to View3", value: Destination.view3(fromView: "ContentView"))
}
.navigationTitle("ContentView")
.navigatable(for: Destination.self)
}
}
import SwiftUI
import Navigator
@Navigatable
public struct MyView: View {
public struct Destination: NavigationDestination {}
public init() {}
public var body: some View {
VStack {
Button("Go back previous page") {
// ⭐️ 1️⃣ - Go back previous page
navigateBack()
}
Button("Go back two pages") {
// ⭐️ 2️⃣ - Specify the number of pages to go back
navigateBack(2)
}
Button("Go back to root page") {
// ⭐️ 3️⃣ - Go back to root page
navigateBackToRoot()
}
}
.navigationTitle("MyView")
.navigatable(for: Destination.self)
}
}
import SwiftUI
import Navigator
@Navigatable
public struct View3: View {
public enum Destination: NavigationDestination {
case view4
}
@State private var isPresented = false
private let fromView: String
public init(fromView: String) {
self.fromView = fromView
}
public var body: some View {
VStack {
Text("View3 (from: \(fromView))")
Button("to View4") {
isPresented = true
}
}
.sheet(isPresented: $isPresented) {
// ⭐️ 1️⃣ - When displaying a sheet, the displayed screen becomes the root of the navigation.
// Therefore, use the `rootView(with:)` method to transition to a screen wrapped in `RootView`.
rootView(with: Destination.view4)
}
.navigationTitle("View3")
}
}