用于在 SwiftUI 中实现清晰导航的框架
如果有任何不清楚的地方,请随时联系我!我很乐意澄清或更新文档,使其更加直观。🚀
如果您觉得这个存储库有用,请随意给它一个 ⭐ 或与您的同事 👩💻👨💻 分享,以帮助壮大使用这个框架的开发者社区!
NavigationModelrequestReviewUIViewControllerRepresentable 支持与 UIKit 的向后兼容性——轻松呈现 SFSafariViewController 或 UIActivityViewController在 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(_) 等操作。
要开始使用,我建议探索 示例应用程序 以了解框架。 之后,您可以自行深入研究。 有关更多详细信息,请查看文档。
我强烈建议首先探索 示例应用程序。 该应用程序具有许多可用于处理导航的命令,并展示了许多应用程序中常见的流程。 它包括从简单的登录/注销流程到具有多个窗口的自定义导航栏的所有内容。
NavigationModel 的依赖项通过初始化程序处理。 为了避免在每次初始化时都传递它们,您可以使用依赖项管理器,例如swift-dependencies。Shared 模块,其中包含例如用于深度链接的对象,这些对象可以在任何模块中使用。 某些服务的实现在主应用程序的 Dependencies 文件夹中。ActionableList 模块用作具有项目的列表屏幕的通用模块。 要查看每个列表包含哪些项目,请检查模块 Data/Factories/... 文件夹中工厂的实现。获取存储库
git clone https://github.com/RobertDresler/SwiftUINavigationSwiftUINavigation)在路径 SwiftUINavigation/Examples.xcodeproj 打开应用程序
运行应用程序
探索应用程序
要开始使用,首先将软件包添加到您的项目中
https://github.com/RobertDresler/SwiftUINavigation,并从 2.1.0 中选择依赖规则直到下一个主版本Package.swift 文件:.package(url: "https://github.com/RobertDresler/SwiftUINavigation", from: "2.1.0")添加软件包后,您可以复制此代码并开始自己探索该框架
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 是顶层层次结构元素。 它位于 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。 NavigationModel 管理特定模块的导航状态。
在 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。
@NavigationModel
最简单的模型,您将在大多数情况下使用它,尤其是在您的屏幕不需要任何选项卡或切换逻辑的情况下。 如果您想创建自己的容器模型,也将使用此宏。
@StackRootNavigationModel
表示通常与 NavigationStack 或 UINavigationController 容器关联的内容。 大多数情况下,您不必创建自己的实现; 您可以使用预定义的容器模型 .stacked / DefaultStackRootNavigationModel 像这样
.stacked(HomeNavigationModel())
如果您想创建自己的实现,可以使用 body(for:) 更新 Model 的 body。
@TabsRootNavigationModel
表示通常与 TabView 或 UITabBarController 容器关联的内容。 您可以像这样创建自己的 TabsRootNavigationModel 实现(有关更多信息,请参见示例应用程序)
@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
}
}
@SwitchedNavigationModel
使用此宏创建一个 NavigationModel 容器,该容器可以在不同的子模型之间动态切换。
此 NavigationModel 适用于以下场景:
NavigationModel,它根据用户是否已登录来显示选项卡根 NavigationModel 或登录 NavigationModel。NavigationModel,它根据用户是否已订阅来显示不同的内容。请参见下面的示例,或者有关实际实现,请查看示例应用程序。
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()
)
)
}
}
.stacked/DefaultStackRootNavigationModel
一个通用的 @StackRootNavigationModel 容器,您可以在大多数情况下使用它,而无需创建自己的容器。 您可以使用 DefaultStackRootNavigationModel 或其静态 .stacked getter 创建它。
@main
struct YourApp: App {
@StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(HomeNavigationModel())
var body: some Scene {
WindowGroup {
RootNavigationView(rootModel: rootNavigationModel)
}
}
}
... in the app
execute(.present(.sheet(.stacked(DetailNavigationModel()))))
StackTabBarToolbarBehavior 作为参数.stacked(..., tabBarToolbarBehavior: .hiddenWhenNotRoot(animated: false))。 这将在根视图不可见时隐藏选项卡栏工具栏。.automatic - 保留默认行为。.hiddenWhenNotRoot(animated:) - 根视图不可见时隐藏选项卡栏 - 可以是动画或非动画。SFSafariNavigationModel
一个在应用程序内浏览器中打开 URL 的模型。
每个 Model 将其状态维护为 NavigationModel 内部的 @Published 属性。 通过使用任何导航 Model 宏,所有可设置的属性 (var)(非延迟加载)都会自动使用 @Published 属性包装器标记,从而允许您在 body 内部观察这些更改。
要执行常见的导航操作,例如追加、呈现或关闭,您需要修改导航状态。在框架中,这是通过使用 NavigationCommand 来处理的。这些命令允许您动态更新状态以反映所需的导航流程。许多命令已在框架中预定义(请参阅示例应用)。
使用 execute(_:) 方法在 NavigationModel 上执行命令。
@NavigationModel
final class HomeNavigationModel {
...
func showDetail() {
execute(.present(.sheet(.stacked(DetailNavigationModel()))))
}
}
.stackAppend/StackAppendNavigationCommandNavigationModel 添加到堆栈 - 您可以将其视为压入 (push)。.stackDropLast/StackDropLastNavigationCommandNavigationModel - 您可以将其视为弹出 (pop)。.stackDropToRoot/StackDropToRootNavigationCommandNavigationModel - 您可以将其视为弹出到根 (pop to root)。.stackSetRoot/StackSetRootNavigationCommandNavigationModel。.stackMap/StackMapNavigationCommand.present/PresentNavigationCommandNavigationModel。PresentOnGivenModelNavigationCommandNavigationModel。.dismiss/DismissNavigationCommandDismissJustFromPresentedNavigationCommandNavigationModel 是最高层级呈现的 NavigationModel,则关闭它。.hide/ResolvedHideNavigationCommandNavigationModel,否则删除堆栈中的最后一个 NavigationModel。.tabsSelectItem/TabsSelectItemNavigationCommand.switchModel/SwitchNavigationCommandNavigationModel 是 SwitchedNavigationModel,则切换其 switchedModel。.openWindow/OpenWindowNavigationCommand.dismissWindow/DismissWindowNavigationCommand.openURL/OpenURLNavigationCommandNavigationEnvironmentTrigger 打开一个 URL(请参阅NavigationEnvironmentTrigger)。该框架旨在允许您轻松创建自己的命令(请参阅示例应用)。
由于使用原生机制呈现视图需要单独的视图修饰符,因此可能会导致意外情况,即同时呈现 fullScreenCover、sheet 和 alert(或者至少您的声明看起来是这样)。为了解决这个问题,我引入了 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()
}
}
}
.fullScreenCover/FullScreenCoverPresentedNavigationModelfullScreenCover。如果您想将新呈现的 Model 包装到堆栈 Model 中,请使用 .stacked 或 DefaultStackRootNavigationModel。.sheet/SheetPresentedNavigationModelsheet(您可以调整 detents 将其显示为底部 Sheet)。如果您想将新呈现的 NavigationModel 包装到堆栈 Model 中,请使用 .stacked 或 DefaultStackRootNavigationModel。.alert/AlertPresentedNavigationModelalert。.confirmationDialog/ConfirmationDialogPresentedNavigationModelactionSheet。当呈现诸如 ConfirmationDialogPresentedNavigationModel 之类的模型时,您可能希望从特定视图呈现它,以便在 iPad 上,它显示为源自该视图的 popover。为此,请使用 presentingNavigationSource(_:) 修饰符来修改视图。
Button(...) { ... }
.presentingNavigationSource(id: "logoutButton")
然后,在呈现它时,将 sourceID 传递给命令的 presentedModel。
.present(
.confirmationDialog(
...,
sourceID: "logoutButton"
)
)
您还可以定义自己的自定义可呈现模型,例如用于处理 PhotosPicker。在这种情况下,您需要使用 registerCustomPresentableNavigationModels(_:) 方法在 NavigationWindow 上注册这些模型(请参阅示例应用)。
一个 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 时,就会触发该消息,以便您知道它正在被释放、关闭或从堆栈中删除。
有时,您需要内容驱动的导航,例如,后端数据或通知将用户引导到特定屏幕。如何处理这些数据完全取决于您。
基本流程如下:
NavigationModel——例如,通过创建一个服务,该服务在 AppNavigationModel 中观察深度链接(如示例应用中所示)。NavigationModel 中处理深度链接——您可以访问诸如 children、presentedModel 之类的属性,或将 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))))
}
}
}
有时,我们需要使用 View 的 API,该 API 只能从 View 本身通过其 EnvironmentValues 触发。为此,我们可以使用 NavigationModel 上的 sendEnvironmentTrigger(_:) 发送 NavigationEnvironmentTrigger。这将调用 DefaultNavigationEnvironmentTriggerHandler,后者调用来自 EnvironmentValues 的值。
OpenURLNavigationEnvironmentTriggerEnvironmentValues.openURL。OpenWindowNavigationEnvironmentTriggeropenWindow。DismissNavigationEnvironmentTriggerEnvironmentValues.dismiss。DismissWindowNavigationEnvironmentTriggerEnvironmentValues.dismissWindow。如果您想自定义处理程序(例如,发送自定义触发器),请继承 DefaultNavigationEnvironmentTriggerHandler 并使用 navigationEnvironmentTriggerHandler(_:) 在 NavigationWindow 上设置它(请参阅示例应用)。
在创建自定义容器视图时,例如在示例应用中的 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,此行为都相同。
欢迎贡献!请随时报告任何问题或请求功能——我很乐意提供帮助!
如果您需要进一步的帮助,请随时联系
如果此仓库对您有所帮助,请考虑使用下面的链接支持我