SwiftUINavigation

用于在 SwiftUI 中实现清晰导航的框架

如果有任何不清楚的地方,请随时联系我!我很乐意澄清或更新文档,使其更加直观。🚀

如果您觉得这个存储库有用,请随意给它一个 ⭐ 或与您的同事 👩‍💻👨‍💻 分享,以帮助壮大使用这个框架的开发者社区!


特性

核心思想 - NavigationModel

在 SwiftUI 中,State/Model/ViewModel 充当视图内容的单一事实来源。这个框架将导航状态分离到一个专用的模型中,称为 NavigationModel

您可以将其视为屏幕/模块,或者您可能将其识别为协调器或路由器。这些 NavigationModel 形成一个导航图,其中每个 NavigationNodel 使用 @Published 属性维护自己的状态。此状态使用原生 SwiftUI 机制呈现,当状态更改时,会发生导航。

例如,当您更新 presentedModel 时,将呈现新的 presentedModel 的相应视图。 NavigationModel 还负责在其 body 中提供屏幕的内容,然后由框架将其集成到视图层次结构中。

下图说明了在使用 SwiftUINavigation 以及 MVVM 或 MV 架构模式时,组件之间的关系

NavigationCommand 表示修改 NavigationModel 导航状态的操作。例如,PresentNavigationCommand 设置 presentedModel。 这些操作可以包括诸如 .stackAppend(_:animated:) (push)、.stackDropLast(_:animated:) (pop)、.present(_:animated:).dismiss(animated:).openURL(_) 等操作。

要开始使用,我建议探索 示例应用程序 以了解框架。 之后,您可以自行深入研究。 有关更多详细信息,请查看文档

入门指南

我强烈建议首先探索 示例应用程序。 该应用程序具有许多可用于处理导航的命令,并展示了许多应用程序中常见的流程。 它包括从简单的登录/注销流程到具有多个窗口的自定义导航栏的所有内容。

如果您更喜欢自己探索该框架,请查看自行探索文档

探索示例应用程序

点击查看详情 👈

先阅读

安装

  1. 获取存储库

    • 克隆存储库:git clone https://github.com/RobertDresler/SwiftUINavigation
    • 下载存储库(不要忘记将下载的文件夹重命名SwiftUINavigation
  2. 在路径 SwiftUINavigation/Examples.xcodeproj 打开应用程序

  3. 运行应用程序

    • 在模拟器上
    • 在真实设备上(设置您的开发团队)
  4. 探索应用程序

自行探索

点击查看详情 👈

要开始使用,首先将软件包添加到您的项目中

添加软件包后,您可以复制此代码并开始自己探索该框架

MV

点击查看示例代码 👈
import SwiftUI
import SwiftUINavigation

@main
struct YourApp: App {

    @StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(
        HomeNavigationModel()
    )

    var body: some Scene {
        WindowGroup {
            RootNavigationView(rootModel: rootNavigationModel)
        }
    }

}

@NavigationModel
final class HomeNavigationModel {

    var body: some View {
        HomeView()
    }

    func showDetail(onRemoval: @escaping () -> Void) {
        let detailNavigationModel = DetailNavigationModel()
            .onMessageReceived { message in
                switch message {
                case _ as RemovalNavigationMessage:
                    onRemoval()
                default:
                    break
                }
            }
        execute(.present(.sheet(.stacked(detailNavigationModel))))
    }

}

struct HomeView: View {

    @EnvironmentNavigationModel private var navigationModel: HomeNavigationModel
    @State private var dismissalCount = 0

    var body: some View {
        VStack {
            Text("Hello, World from Home!")
            Text("Detail dismissal count: \(dismissalCount)")
            Button(action: { showDetail() }) {
                Text("Go to Detail")
            }
        }
    }

    func showDetail() {
        navigationModel.showDetail(onRemoval: { dismissalCount += 1 })
    }

}

@NavigationModel
final class DetailNavigationModel {

    var body: some View {
        DetailView()
    }

}

struct DetailView: View {

    @EnvironmentNavigationModel private var navigationModel: DetailNavigationModel

    var body: some View {
        Text("Hello world from Detail!")
    }

}

MVVM

点击查看示例代码 👈
import SwiftUI
import SwiftUINavigation

@main
struct YourApp: App {

    @StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(
	HomeNavigationModel()
    )

    var body: some Scene {
        WindowGroup {
            RootNavigationView(rootModel: rootNavigationModel)
        }
    }

}

@NavigationModel
final class HomeNavigationModel {

    private lazy var viewModel = HomeViewModel(navigationModel: self)

    var body: some View {
        HomeView(viewModel: viewModel)
    }

    func showDetail() {
        let detailNavigationModel = DetailNavigationModel()
            .onMessageReceived { [weak self] message in
                switch message {
                case _ as RemovalNavigationMessage:
                    self?.viewModel.dismissalCount += 1
                default:
                    break
                }
            }
        execute(.present(.sheet(.stacked(detailNavigationModel))))
    }

}

@MainActor class HomeViewModel: ObservableObject {

    @Published var dismissalCount = 0
    private unowned let navigationModel: HomeNavigationModel

    init(dismissalCount: Int = 0, navigationModel: HomeNavigationModel) {
        self.dismissalCount = dismissalCount
        self.navigationModel = navigationModel
    }

}

struct HomeView: View {

    @EnvironmentNavigationModel private var navigationModel: HomeNavigationModel
    @ObservedObject var viewModel: HomeViewModel

    var body: some View {
        VStack {
            Text("Hello, World from Home!")
            Text("Detail dismissal count: \(viewModel.dismissalCount)")
            Button(action: { navigationModel.showDetail() }) {
                Text("Go to Detail")
            }
        }
    }

}

@NavigationModel
final class DetailNavigationModel {

    private lazy var viewModel = DetailViewModel(navigationModel: self)

    var body: some View {
        DetailView(viewModel: viewModel)
    }

}

@MainActor class DetailViewModel: ObservableObject {

    private unowned let navigationModel: DetailNavigationModel

    init(navigationModel: DetailNavigationModel) {
        self.navigationModel = navigationModel
    }

}

struct DetailView: View {

    @EnvironmentNavigationModel private var navigationModel: DetailNavigationModel
    @ObservedObject var viewModel: DetailViewModel

    var body: some View {
        Text("Hello world from Detail!")
    }

}

文档

要查看框架的实际应用,请查看示例应用程序中的代码。 如果有任何不清楚的地方,请随时联系我! 我很乐意澄清或更新文档,使其更加直观。 🚀

RootNavigationView

点击此处查看更多 👈RootNavigationView 是顶层层次结构元素。 它位于 WindowGroup 内部,并持有对根 NavigationModel 的引用。 避免将一个 RootNavigationView 嵌套在另一个 RootNavigationView 内部——仅在顶层使用它。

唯一的例外是将 SwiftUINavigation 集成到使用基于 UIKit 的导航的现有项目中。 在这种情况下,RootNavigationView 允许您在 SwiftUI 和 UIKit 导航模式之间建立桥梁。

根模型应使用 @StateObject 属性包装器创建,例如,在您的 App

@main
struct YourApp: App {

    @StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(HomeNavigationModel())

    var body: some Scene {
        WindowGroup {
            RootNavigationView(rootModel: rootNavigationModel)
        }
    }

}

NavigationModel

点击此处查看更多 👈

NavigationModel 表示导航图中的单个模型,类似于您可能知道的协调器或路由器。 通常,每个模块或屏幕都有一个 NavigationModelNavigationModel 管理特定模块的导航状态。

body 中,您返回模块视图的实现。

下面显示了最小的工作示例。 如果您支持 iOS 17+,则 YourModel 可以使用 @Observable 宏。 在这种情况下,您可以将其作为环境值分配到 body 中,而不是在初始化程序中传递它。

MVVM

@NavigationModel
final class YourNavigationModel {

    private lazy var viewModel = YourViewModel(navigationModel: self)

    var body: some View {
        YourView(viewModel: viewModel)
    }

}

@MainActor class YourViewModel: ObservableObject {

    private unowned let navigationModel: YourNavigationModel

    init(navigationModel: YourNavigationModel) {
        self.navigationModel = navigationModel
    }

}

struct YourView: View {

    @EnvironmentNavigationModel private var navigationModel: YourNavigationModel
    @ObservedObject var viewModel: YourViewModel

    var body: some View {
        Text("Hello, World from Your Module!")
    }

}

MV

@NavigationModel
final class YourNavigationModel {

    var body: some View {
        YourView()
    }

}

struct YourView: View {

    @EnvironmentNavigationModel private var navigationModel: YourNavigationModel

    var body: some View {
        Text("Hello, World from Your Module!")
    }

}

预定义的宏

请记住,使用这些宏之一标记的类的任何属性,如果可设置 (var) 且不是延迟加载,则会自动标记为 @Published

@TabsRootNavigationModel
final class MainTabsNavigationModel {

    enum Tab {
        case home
        case settings
    }

    var selectedTabModelID: AnyHashable
    var tabsModels: [any TabModel]

    init(initialTab: Tab) {
        selectedTabModelID = initialTab
        tabsModels = [
            DefaultTabModel(
                id: Tab.home,
                image: Image(systemName: "house"),
                title: "Home",
                navigationModel: .stacked(HomeNavigationModel())
            ),
            DefaultTabModel(
                id: Tab.settings,
                image: Image(systemName: "gear"),
                title: "Settings",
                navigationModel: .stacked(SettingsNavigationModel())
            )
        ]
    }

    func body(for content: TabsRootNavigationModelView<MainTabsNavigationModel>) -> some View {
        content // Modify default content if needed
    }

}
final class UserService {
    @Published var isUserLogged = false
}

@SwitchedNavigationModel
final class AppNavigationModel {

    var switchedModel: (any NavigationModel)?
    let userService: UserService

    init(userService: UserService) {
        self.userService = userService
    }

    func body(for content: SwitchedNavigationModelView<AppNavigationModel>) -> some View {
        content
            .onReceive(userService.$isUserLogged) { [weak self] in self?.switchModel(isUserLogged: $0) }
    }

    private func switchModel(isUserLogged: Bool) {
        execute(
            .switchModel(
                isUserLogged
                    ? MainTabsNavigationModel(initialTab: .home)
                    : LoginNavigationModel()
            )
        )
    }

}

预定义的模型

NavigationModel 的状态

点击此处查看更多 👈

每个 Model 将其状态维护为 NavigationModel 内部的 @Published 属性。 通过使用任何导航 Model 宏,所有可设置的属性 (var)(非延迟加载)都会自动使用 @Published 属性包装器标记,从而允许您在 body 内部观察这些更改。

NavigationCommand

点击此处查看更多 👈

要执行常见的导航操作,例如追加、呈现或关闭,您需要修改导航状态。在框架中,这是通过使用 NavigationCommand 来处理的。这些命令允许您动态更新状态以反映所需的导航流程。许多命令已在框架中预定义(请参阅示例应用)。

使用 execute(_:) 方法在 NavigationModel 上执行命令。

@NavigationModel
final class HomeNavigationModel {

    ...

    func showDetail() {
        execute(.present(.sheet(.stacked(DetailNavigationModel()))))
    }

}

预定义命令

堆栈命令
呈现命令
关闭命令
其他命令

该框架旨在允许您轻松创建自己的命令(请参阅示例应用)。

PresentedNavigationModel

点击此处查看更多 👈

由于使用原生机制呈现视图需要单独的视图修饰符,因此可能会导致意外情况,即同时呈现 fullScreenCoversheetalert(或者至少您的声明看起来是这样)。为了解决这个问题,我引入了 PresentedNavigationModel 的概念。每个 NavigationModel 在内部维护一个单独的 presentedNode 属性。

您不直接呈现 NavigationModel,而是只呈现一个 PresentedNavigationModel,它持有您的 NavigationModel(例如,DetailNavigationModel)。PresentedNavigationModel 可以是 FullScreenCoverPresentedNavigationModel,表示作为 fullScreenCover 呈现的模型。

此方法还允许自定义实现,例如照片选择器。要呈现模型,请使用 PresentedNavigationModel 执行 PresentNavigationCommand

@NavigationModel
final class ProfileNavigationModel {

    ...

    func showEditor() {
        // Present fullScreenCover
        execute(.present(.fullScreenCover(.stacked(ProfileEditorNavigationModel()))))
        // Present sheet
        execute(.present(.sheet(.stacked(ProfileEditorNavigationModel()))))
        // Present sheet with editor and pushed connected services detail from the editor
        execute(.present(.sheet(.stacked([ProfileEditorNavigationModel(), ConnectedServicesDetailNavigationModel()])))
        // Present not wrapped in stack
        execute(.present(.sheet(SFSafariNavigationModel(...))))
	// Present sheet and then immediately present another one
	let presentedModel = ProfileEditorNavigationModel()
        execute(.present(.sheet(.stacked(presentedModel))))
        presentedModel.execute(.present(.sheet(.stacked(NameEditorNavigationModel()))))
    }

}

struct ProfileView: View {

    @EnvironmentNavigationModel private var navigationModel: ProfileNavigationModel

    var body: some View {
        Button("Show editor") {
            navigationModel.showEditor()
        }
    }

}

预定义的 PresentedNavigationModels

当呈现诸如 ConfirmationDialogPresentedNavigationModel 之类的模型时,您可能希望从特定视图呈现它,以便在 iPad 上,它显示为源自该视图的 popover。为此,请使用 presentingNavigationSource(_:) 修饰符来修改视图。

Button(...) { ... }
    .presentingNavigationSource(id: "logoutButton")

然后,在呈现它时,将 sourceID 传递给命令的 presentedModel

.present(
    .confirmationDialog(
        ...,
        sourceID: "logoutButton"
    )
)

您还可以定义自己的自定义可呈现模型,例如用于处理 PhotosPicker。在这种情况下,您需要使用 registerCustomPresentableNavigationModels(_:) 方法在 NavigationWindow 上注册这些模型(请参阅示例应用)。

NavigationMessage

点击此处查看更多 👈

一个 NavigationModel 可以通过消息侦听器发送 NavigationMessage。您可以使用 onMessageReceived(_:)/addMessageListener(_:) 添加侦听器,然后使用 sendMessage(_:) 发送消息。接收者可以检查消息的类型并进行相应的处理。

execute(
    .stackAppend(
        DetailNavigationModel()
            .onMessageReceived { [weak self] in 
                switch message {
                case _ as RemovalNavigationMessage:
                    // You can handle it how you want, these are just examples
                    // When using MV you can call closure from method's argument
                    onDetailRemoval()
                    // When using MVVM you can access `viewModel` from your `NavigationModel`
                    self?.viewModel.handleDetailRemoval()
                default:
                    // Or you can do nothing
                    break
                } 
            }
    )
)

该框架提供了一个预定义的消息 RemovalNavigationMessage,每当从其 parent 中删除 NavigationModel 时,就会触发该消息,以便您知道它正在被释放、关闭或从堆栈中删除。

深度链接

点击此处查看更多 👈

有时,您需要内容驱动的导航,例如,后端数据或通知将用户引导到特定屏幕。如何处理这些数据完全取决于您。

基本流程如下:

  1. 接收深度链接——例如,从点击通知后的后端。
  2. 将深度链接传递给 NavigationModel——例如,通过创建一个服务,该服务在 AppNavigationModel 中观察深度链接(如示例应用中所示)。
  3. 在特定的 NavigationModel 中处理深度链接——您可以访问诸如 childrenpresentedModel 之类的属性,或将 NavigationModel 转换为 TabsRootNavigationModel 以检索 tabsModels

示例应用中显示了一个示例方法,其中 HandleDeepLinkNavigationCommandFactory 服务解析自定义的 HandleDeepLinkNavigationCommand。这允许开发人员根据他们的需要处理深度链接。此流程的确切实现完全取决于您。

这是一个如何处理不同深度链接的简单示例。

enum DeepLink {
    case detail(DetailInputData)
    case detailWithPushedEditor(DetailInputData, DetailEditorInputData)
    case detailWithPresentedEditor(DetailInputData, DetailEditorInputData)
}

@NavigationModel
final class AppNavigationModel {

    ...

    func handleDeepLink(_ deepLink: DeepLink) {
        switch deepLink {
        /// Present just detail as sheet
        case let .detail(detailInputData):
            let detailModel = DetailNavigationModel(inputData: detailInputData)
            execute(.present(.sheet(.stacked(detailModel))))
        /// Present detail as sheet with already pushed detail editor
        case let .detailWithPushedEditor(detailInputData, detailEditorInputData):
            let detailModel = DetailNavigationModel(inputData: detailInputData)
            let detailEditorModel = DetailEditorNavigationModel(inputData: detailEditorInputData)
            execute(.present(.sheet(.stacked([detailModel, detailEditorModel]))))
        /// Present detail as sheet and after present detail editor as sheet over detail sheet
        case let .detailWithPresentedEditor(detailInputData, detailEditorInputData):
            let detailModel = DetailNavigationModel(inputData: detailInputData)
            let detailEditorModel = DetailEditorNavigationModel(inputData: detailEditorInputData)
            execute(.present(.sheet(.stacked(detailModel))))
            detailModel.execute(.present(.sheet(.stacked(detailEditorModel))))
        }
    }

}

NavigationEnvironmentTrigger

点击此处查看更多 👈

有时,我们需要使用 View 的 API,该 API 只能从 View 本身通过其 EnvironmentValues 触发。为此,我们可以使用 NavigationModel 上的 sendEnvironmentTrigger(_:) 发送 NavigationEnvironmentTrigger。这将调用 DefaultNavigationEnvironmentTriggerHandler,后者调用来自 EnvironmentValues 的值。

预定义的触发器

如果您想自定义处理程序(例如,发送自定义触发器),请继承 DefaultNavigationEnvironmentTriggerHandler 并使用 navigationEnvironmentTriggerHandler(_:)NavigationWindow 上设置它(请参阅示例应用)。

NavigationModelResolvedView

点击此处查看更多 👈

在创建自定义容器视图时,例如在示例应用中的 SegmentedTabsNavigationModel 中,请使用 NavigationModelResolvedView 在视图层次结构中显示 Model(例如,这就是 DefaultStackRootNavigationModel 在内部工作的方式)。

自定义过渡

点击此处查看更多 👈

自 iOS 18+ 起,堆栈支持诸如缩放之类的自定义过渡(请参阅示例应用)。

调试

点击此处查看更多 👈

要启用调试打印,请设置以下内容:

NavigationConfig.shared.isDebugPrintEnabled = true

默认情况下,这将打印具有其 ID 的 Model 的启动和完成(反初始化),帮助您确保没有内存泄漏。

. [SomeNavigationModel E34...]: Started
. [SomeNavigationModel F34...]: Finished

您还可以使用 printDebugGraph() 从给定的 NavigationModel 及其后继节点打印调试图。这将帮助您了解层次结构。

关系

点击此处查看更多 👈

您可以使用不同的关系来探索图。重要的是要知道父/子关系是自动处理的,因此您只需调用命令。如果您正在实现自定义容器,则情况确实如此,在这种情况下,您可以简单地覆盖 children(请参阅示例应用中的 SegmentedTabsNavigationModel)。

常见问题

点击此处查看更多 👈

问:使用 AnyView 会导致性能问题吗?

答:根据我的发现,不应该会。AnyView 仅在导航层的顶部使用,除非有导航操作,否则不会重新绘制。无论您是否使用 AnyView,此行为都相同。

贡献、问题和功能请求

欢迎贡献!请随时报告任何问题或请求功能——我很乐意提供帮助!

联系方式

如果您需要进一步的帮助,请随时联系

支持

如果此仓库对您有所帮助,请考虑使用下面的链接支持我

"Buy me a coffee ☕️ or just support me"