🚗 Navigator(SwiftUI 多模块导航库)

Navigator 是一个用于 SwiftUI 的 Router 库。


特性

它支持在模块化应用程序中,按功能模块实现独立的路由。

动机

在一个由多个功能模块组成的应用程序中,我们希望消除功能模块之间的依赖关系,以便于在每个功能模块上进行独立开发。
然而,在某些情况下,目标屏幕可能来自不同的功能模块。
在这种情况下,我们希望在无需知道目标屏幕具体类型的情况下管理屏幕跳转。

用法

安装

Navigator 通过 Swift Package Manager 分发。
请将以下内容添加到 Package.swift 中的 dependencies 中。

.package(url: "https://github.com/taji-taji/Navigator", from: "0.0.1"),

步骤

步骤 1

首先,将屏幕跳转的初始屏幕包裹在 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️⃣ viewProvider 参数是一个闭包,其签名为 @ViewBuilder viewProvider: @escaping (any NavigationDestination) -> some View。在本例中,一个遵循此签名的名为 viewProvider 的方法定义如下

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()
}

此方法(或闭包)将抽象的目标页面映射到其对应的具体 View 类型,因此它应该在依赖于每个功能模块的模块中定义,例如应用程序目标模块。

⭐️ 3️⃣ 在此阶段,由于尚未在每个功能模块中定义目标页面,您可以返回 EmptyView() 作为占位符。(或者,您也可以使用 fatalError()。)

当然,您也可以直接将闭包作为参数写入 RootView

步骤 2

在每个功能模块中使用 Navigator
在以下示例中,我们将在名为 Feature1 的模块中使用带有名为 View1 的屏幕的 Navigator

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)
    }
}

或者使用 @Navigatable 宏。 通过应用 @Navigatable 宏,可以省略以下内容

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)
    }
}

警告

遵循 Navigatable 协议的 View 必须在视图层级的顶层有一个 RootView。 这是因为它内部使用了由 RootView 注入的 EnvironmentObject

由于上述原因,当使用 Preview 时,您需要将其包裹在 RootView 中。 这是 View1 的预览示例

#Preview {
    RootView(
        viewProvider: { _ in
            Text("Preview")
        },
        content: {
            View1()
        }
    )
}

步骤 3

完成在步骤 1 中定义的 viewProperty 方法。

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()
    }
}

其他用法

使用 NavigationLink

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)
    }
}

Present Sheet (呈现 Sheet 视图)

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")
    }
}