Swift 导航

CI Slack

受 SwiftUI 启发,为所有 Swift 平台带来简单而强大的导航工具。

概述

这个库包含一系列工具,它们构成了为 Apple 平台(如 SwiftUI、UIKit 和 AppKit)以及非 Apple 平台(如 Windows、Linux、Wasm 等)构建强大的状态管理和导航 API 的基础。

SwiftNavigation 库构成了可以构建更高级工具的基础,例如 UIKitNavigation 和 SwiftUINavigation 库。 主要提供两个工具:

除了这些工具之外,还有一些补充概念,允许您构建更强大的工具,例如 UITransaction,它将动画和其他数据与状态更改相关联;以及 UINavigationPath,它是一种类型擦除的数据堆栈,有助于描述基于堆栈的导航。

所有这些工具都构成了如何为 SwiftUI、UIKit、AppKit 甚至非 Apple 平台构建更强大和更可靠的工具的基础。

SwiftUI

SwiftUI 已经配备了非常强大的导航 API,但在某些方面仍存在不足。 特别是从枚举状态驱动导航,以便您可以获得编译时保证,即一次只能激活一个目的地。

例如,假设您有一个功能,可以显示一个用于创建项目的 Sheet,深入到用于编辑项目的视图,并且可以显示一个用于确认删除项目的 Alert。 从技术上讲,可以用 3 个单独的可选项来建模:

@Observable
class FeatureModel {
  var addItem: AddItemModel?
  var deleteItemAlertIsPresented: Bool
  var editItem: EditItemModel?
}

然后在视图中,可以使用 sheetnavigationDestinationalert 视图修饰符来描述导航类型。

.sheet(item: $model.addItem) { addItemModel in
  AddItemView(model: addItemModel)
}
.alert("Delete?", isPresented: $model.deleteItemAlertIsPresented) {
  Button("Yes", role: .destructive) { /* ... */ }
  Button("No", role: .cancel) {}
}
.navigationDestination(item: $model.editItem) { editItemModel in
  EditItemModel(model: editItemModel)
}

一开始这很好,但也会给您的功能带来很多不必要的复杂性。 这 3 个可选项意味着技术上有 8 种不同的状态:都可以是 nil,一个是 非-nil,两个可以是 非-nil,或者全部三个都可以是 非-nil。 但只有 4 种状态有效:要么都是 nil,要么只有一个是 非-nil

通过允许其他这 4 种无效状态,我们可能会意外地告诉 SwiftUI 同时显示 Sheet 和 Alert,但这在 SwiftUI 中是无效的,并且 SwiftUI 甚至会在控制台中打印一条消息,告知您将来它实际上可能会导致您的应用程序崩溃。

幸运的是,Swift 提供了处理这种情况的完美工具:枚举! 它们允许您简洁地定义一种类型,该类型可以是多种情况之一。 因此,我们可以将 3 个可选项重构为具有 3 个 case 的枚举,然后保留一块可选状态:

@Observable
class FeatureModel {
  var destination: Destination?

  enum Destination {
    case addItem(AddItemModel)
    case deleteItemAlert
    case editItem(EditItemModel)
  }
}

这更简洁,并且我们获得了编译时验证,即最多可以同时激活一个目的地。 但是,SwiftUI 没有提供从该模型驱动导航的工具。 这就是 SwiftUINavigation 工具变得有用的地方。

重要提示

要访问下面描述的工具,您必须依赖 SwiftNavigation 包(请参阅 安装)并导入 SwiftUINavigation 库。

我们首先使用 @CasePathable 宏注释 Destination 枚举,这允许人们使用点语法引用枚举的 case,就像对结构和属性所做的那样:

+import SwiftNavigation 

+@CasePathable
 enum Destination {
   // ...
 }

现在,可以使用简单的点链语法从 destination 属性的特定 case 派生绑定:

import SwiftUINavigation 
// ...
.sheet(item: $model.destination.addItem) { addItemModel in
  AddItemView(model: addItemModel)
}
.alert("Delete?", isPresented: Binding($model.destination.deleteItemAlert)) {
  Button("Yes", role: .destructive) { /* ... */ }
  Button("No", role: .cancel) {}
}
.navigationDestination(item: $model.destination.editItem) { editItemModel in
  EditItemView(model: editItemModel)
}

注意

对于 alert,我们使用的是特殊的 Binding 初始化程序,它将 Binding<Void?> 转换为 Binding<Bool>

现在,我们有一种简洁的方式来描述一个功能可以导航到的所有目的地,并且我们仍然可以使用 SwiftUI 的导航 API。

UIKit

重要提示

要访问下面描述的工具,您必须依赖 SwiftNavigation 包并导入 UIKitNavigation 库。

与 SwiftUI 不同,UIKit 没有配备状态驱动的导航工具。 它的导航工具是“即发即弃”的,这意味着您只需调用一个方法来触发导航,但在您功能的状态中没有任何表示。

例如,要从按钮按下显示一个 Sheet,您可以简单地执行:

let button = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
  present(SettingsViewController(), animated: true)
})

这使得开始导航变得容易,但是正如 SwiftUI 教会我们的那样,能够从状态驱动导航是非常强大的。 它允许您将更多的功能逻辑封装在一个隔离且可测试的域中,并且它可以免费解锁深度链接,因为只需要构造一块代表您想要导航到的位置的状态,将其交给 SwiftUI,然后让它处理剩下的事情。

UIKitNavigation 库为 UIKit 带来了一套强大的导航工具,这些工具深受 SwiftUI 的启发。 例如,如果您有一个像上面 SwiftUI 部分讨论的功能模型:

import SwiftNavigation 

@Observable
class FeatureModel {
  var destination: Destination?

  @CasePathable
  enum Destination {
    case addItem(AddItemModel)
    case deleteItemAlert
    case editItem(EditItemModel)
  }
}

……那么可以使用库中的工具在视图控制器中驱动导航:

import UIKitNavigation 

class FeatureViewController: UIViewController {
  @UIBindable var model: FeatureModel

  func viewDidLoad() {
    super.viewDidLoad()

    // Set up view hierarchy

    present(item: $model.destination.addItem) { addItemModel in
      AddItemViewController(model: addItemModel)
    }
    present(isPresented: UIBinding($model.destination.deleteItemAlert)) {
      let alert = UIAlertController(title: "Delete?", message: message, preferredStyle: .alert)
      alert.addAction(UIAlertAction(title: "Yes", style: .destructive))
      alert.addAction(UIAlertAction(title: "No", style: .cancel))
      return alert
    }
    navigationDestination(item: $model.destination.editItem) { editItemModel in
      EditItemViewController(model: editItemModel)
    }
  }
}

通过使用库的导航工具,我们可以保证模型将与视图保持同步。 当状态变为 非-nil 时,将触发相应的导航形式,并且当呈现的视图被解除时,状态将为 nil

SwiftUI 的另一个强大之处在于,它能够在其可观察模型中的状态发生更改时更新其 UI。 并且由于 Swift 的观察工具,这可以隐式且最小地完成:无论在视图的 body 中访问哪些字段都会被自动跟踪,以便在它们更改时视图会更新。

我们的 UIKitNavigation 库附带一个工具,可以将这种能力带到 UIKit,它被称为 observe

observe { [weak self] in
  guard let self else { return }
  
  countLabel.text = "Count: \(model.count)"
  factLabel.isHidden = model.fact == nil 
  if let fact = model.fact {
    factLabel.text = fact
  }
  activityIndicator.isHidden = !model.isLoadingFact
}

无论在 observe 内部访问哪些字段(例如上面的 countfactisLoadingFact)都会被自动跟踪,以便每当它们发生变化时,都会再次调用 observe 的尾随闭包,从而允许我们使用最新的数据更新 UI。

所有这些工具都建立在 Swift 强大的 Observation 框架之上。 但是,该框架仅适用于较新版本的 Apple 平台:iOS 17+、macOS 14+、tvOS 17+ 和 watchOS 10+。 但是,感谢我们对 Swift 观察工具的反向移植(请参阅 Perception),您可以立即使用我们的工具,一直追溯到 iOS 13 时代的平台。

非 Apple 平台

此库提供的工具还可以构成构建非 Apple 平台(如 Windows、Linux、Wasm 等)的导航工具的基础。 我们目前没有提供任何此类工具,但可以在外部构建它们。

例如,在 Wasm 中,可以使用 observe(isolation:_:) 函数来观察模型的更改并更新 DOM:

import JavaScriptKit

var countLabel = document.createElement("span")
_ = document.body.appendChild(countLabel)

let token = observe {
  countLabel.innerText = .string("Count: \(model.count)")
}

并且可以从状态驱动导航,例如 alert:

alert(isPresented: $model.isShowingErrorAlert) {
  "Something went wrong"
}

并且您可以构建更高级的工具来呈现和解除浏览器中的 <dialog>

示例

此仓库附带了许多示例,用于演示如何使用该库解决常见且复杂的导航问题。 查看 这个 目录以查看所有这些示例,包括:

了解更多

Swift Navigation 的工具的动机和设计来自 Point-Free 上的许多剧集,这是一个探索函数式编程和 Swift 语言的视频系列,由 Brandon WilliamsStephen Celis 主持。

您可以 在此处 观看所有剧集。

video poster image

社区

如果您想讨论这个库或者对如何使用它来解决特定问题有疑问,可以在许多地方与其他 Point-Free 爱好者进行讨论:

安装

您可以通过将 Swift Navigation 添加为包依赖项来将其添加到 Xcode 项目中。

https://github.com/pointfreeco/swift-navigation

如果您想在 SwiftPM 项目中使用 Swift Navigation,只需将其添加到 Package.swift 中的 dependencies 子句中即可:

dependencies: [
  .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.0.0")
]

文档

Swift Navigation API 的最新文档可在 此处 获得。

许可证

该库是在 MIT 许可证下发布的。 有关详细信息,请参阅 LICENSE