在使用UIKit开发iOS应用时,许多开发者都使用了Coordinator模式来抽象各个view组件之间的联系。这增加了view components(如View Controller)的可重用性,减少了UIViewController实现频繁且过度拥挤的责任,并使视图组件之间的联系(如转场和数据交换)更加可见。
一旦你理解了这个概念,在UIKit中使用Coordinator模式开发应用就非常简单了。 (为了进一步简化实现,我们引入了XCoordinator,这个框架提供了使协调器和转场尽可能优雅和强大的工具。) 然而,随着SwiftUI在大约两年半前发布并逐渐成熟,如何在SwiftUI中使用Coordinator模式的问题出现了。
事实证明,这并不像用它们的SwiftUI等价物替换所有的UIKit视图(和视图控制器)那么容易。 这是由于从根本上改变了,视图不再是class对象,而是structs。在本文中,我们将探讨如何使用声明性UI框架(如SwiftUI)实现Coordinator模式,然后创建一个小示例应用程序来展示如何应用该模式。如果你迫不及待地想看看它是如何工作的,下面是我们的示例应用repo的链接: SwiftUI-Coordinators-Example
The Coordinator Pattern
协调模式是由Soroush Khanlou在2015年引入iOS社区的,它将视图控制器之间的转场逻辑提取为我们称之为Coordinator的独立组件。你可以将它们视为不同场景(视图控制器或“屏幕”)之间的“粘合剂(glue)”,因为它们的工作是控制这些场景之间的数据流,定义在某一事件的基础上进行哪种转场过渡,并创建新的场景。

通过结合MVVM(Model-View-ViewModel)体系结构模式,我们可以清楚地分离 view components and business logic 之间的关注点。与其他应用程序架构一样,Model components是为应用程序的持久性、联网和计算能力而构建的。 ViewModels从Models中检索到的信息并将其转发给View组件,这些组件依次定义用户界面。 结合Coordinator模式,我们可以使用Coordinator在不同的视图模型之间交换信息,并定义转场逻辑。
How SwiftUI is Different
我们现在如何在SwiftUI中实现这一功能? 正如在介绍中提到的,我们不能简单地使用与UIKit中相同的架构。原因是SwiftUI遵循声明式方法。我们没有描述如何用长期存在的对象构建用户界面的过程,而是简单地根据应用程序的当前状态定义视图应该是什么样子。只要状态改变,就会重新创建视图,并相应地更新用户界面。因此,我们可以将SwiftUI视图定义为当前状态的函数,或者用数学方法:

每当状态改变时,就会创建一个新视图:

因此,在SwiftUI中执行转场不像在UIKit中那样是一个简单的方法调用,这使得采用Coordinator更加困难。相反,我们需要考虑我们的coordinators在UIKit中扮演什么角色,以及如何将这些独立的责任转移到SwiftUI。
在UIKit中,Coordinator有以下职责:
- 它控制view的上下文,也就是说无论它是在UINavigationController中使用,还是在UITabBarController中使用。
- 它定义了当某些事件发生时执行哪些转场,比如呈现一个新的视图控制器或将其推到当前导航堆栈上。
- 它创建视图控制器并管理它们之间的内存交换(例如,通过创建新的视图模型)。
现在,让我们看看如何将这些责任转移到SwiftUI!
A Coordinator in SwiftUI
我们将使用MVVM方法(非常类似于之前的博客文章中的MVVM方法),因此每个视图将有一个符合ObservableObject的对应view model,用于定义view's state。但最疯狂的部分来了:不仅我们的view有view models——我们的Coordinator也有view models!
SwiftUI中的Coordinator由两个组件组成:
- a coordinator view,
- and a coordinator object.
coordinator view是视图层次结构的一部分,它控制子视图的转场逻辑和整个视图上下文。coordinator view需要一个coordinator object来创建view models,并允许不同场景之间的通信。类似于UIKit中的Coordinator,我们可以从子场景的view models中引用Coordinator,并基于view model的业务逻辑在Coordinator中触发转场事件。
❓为什么我们把Coordinator分成两部分?
在SwiftUI中,Coordinator有两项不同的职责需要处理: View的上下文需要由视图层次结构的一部分的视图来控制。但是我们不能控制view的生命周期。这可以很容易地通过ObservableObject实现。 我们可以很容易地通过引用来传递它,并使它符合Identifiable协议。
Coordinators in Practice
现在让我们看看在实践中是什么样子! 首先,我们将探讨Coordinator View及其伴生的Coordinator Object的一般设置,然后通过这个设置处理各个转场及其使用。
General Setup
我们从创建一个简单的SwiftUI视图开始。它有一个ObservedObject属性作为它的 coordinator object:
struct CoordinatorView: View {
@ObservedObject var object: CoordinatorObject
var body: some View {
/* create the view based on the state of <code data-enlighter-language="generic" class="EnlighterJSRAW">object</code> */
}
}
因此,这个CoordinatorObject对象必须符合ObservableObject协议。
class CoordinatorObject: ObservableObject {
init() {
/* setup object */
}
}
在这个设置中,CoordinatorView设置通用视图上下文。它根据对象的state创建NavigationViews或TabViews,引入表、弹窗和NavigationLinks。
CoordinatorObject以*@Published-wrapped*属性的形式保持其状态。这种状态主要由视图模型或上下文相关的信息组成,例如创建某个工作表或弹出窗口所需的信息。其中一些视图模型可能有对协调器对象的(weak or unowned)引用。 CoordinatorObject提供了当某个事件发生时要触发的方法,而不是对协调器对象的状态本身进行操作。
听起来有点理论化?“有时你必须先跑后走”,所以让我们看看一些常见的用户界面组件,来了解一下这些新的coordinators!
NavigationView
Navigation Views最好在coordinator view中创建,而不是在某个view本身中创建。这允许view在不同的上下文中被重用,而不需要更改其代码。
首先,让我们来看看底层逻辑和状态管理:
class CoordinatorObject: ObservableObject {
@Published var listViewModel: ListViewModel!
@Published var detailViewModel: DetailViewModel?
init() {
self.listViewModel = ListViewModel(coordinator: self)
}
func open(_ item: ListItem) {
self.detailViewModel = DetailViewModel(item: item, coordinator: self)
}
}
这个CoordinatorObject跟踪listViewModel(负责主视图)和一个可选的detailViewModel(负责细节视图)。每当调用open方法时,我们都会创建一个新的DetailViewModel,并将其分配给我们的DetailViewModel属性。当这个属性被设置为非nil视图模型时,我们打算触发一个push转场。但那是我们的CoordinatorView的工作!
❓为什么listViewModel被强制解包?
因为它的值取决于CoordinatorObject是否完全初始化。listViewModel在创建时需要coordinator本身,而这只能发生在CoordinatorObject完全初始化之后。一旦创建了它,就可以安全地强制解包,只要我们不显式地将它设置为nil。通常,我们可以为此目的使用lazy属性,但是wrapped properties不能被标记为lazy属性。
现在,让我们创建相应的coordinator view:
struct CoordinatorView: View {
@ObservedObject var object: CoordinatorObject
var body: some View {
NavigationView {
ListView(viewModel: object.listViewModel)
.navigation(item: object.detailViewModel) { DetailView(viewModel: $0) }
}
}
}
我们所做的就是将我们现有的ListView包装在一个NavigationView中,并且——正如你可能已经注意到的——为ListView添加一个navigation视图修饰符。这是我们创建的一个自定义视图修饰符,专门用于向NavigationView添加一个新的“页面”(带有我们想要显示的视图)。它的使用方式类似于表单和弹出窗口,并在内部使用NavigationLink。如果您对它的工作原理感兴趣,可以在这里找到它的源代码。
extension View {
func onNavigation(_ action: @escaping () -> Void) -> some View {
let isActive = Binding(
get: { false },
set: { newValue in
if newValue {
action()
}
}
)
return NavigationLink(
destination: EmptyView(),
isActive: isActive
) {
self
}
}
func navigation<Item, Destination: View>(
item: Binding<Item?>,
@ViewBuilder destination: (Item) -> Destination
) -> some View {
let isActive = Binding(
get: { item.wrappedValue != nil },
set: { value in
if !value {
item.wrappedValue = nil
}
}
)
return navigation(isActive: isActive) {
item.wrappedValue.map(destination)
}
}
func navigation<Destination: View>(
isActive: Binding<Bool>,
@ViewBuilder destination: () -> Destination
) -> some View {
overlay(
NavigationLink(
destination: isActive.wrappedValue ? destination() : nil,
isActive: isActive,
label: { EmptyView() }
)
)
}
}
extension NavigationLink {
init<T: Identifiable, D: View>(item: Binding<T?>,
@ViewBuilder destination: (T) -> D,
@ViewBuilder label: () -> Label) where Destination == D? {
let isActive = Binding(
get: { item.wrappedValue != nil },
set: { value in
if !value {
item.wrappedValue = nil
}
}
)
self.init(
destination: item.wrappedValue.map(destination),
isActive: isActive,
label: label
)
}
}
我们现在可以显示一个NavigationView并从CoordinatorObject推送新的子视图,但是我们还没有查看从ListView触发推送转场的机制。让我们做下一个!
struct ListView: View {
@ObservedObject var viewModel: ListViewModel
var body: some View {
List(viewModel.items) { item in
Cell(item)
.onNavigation { viewModel.open(item) }
}
}
}
ListView显示了一个项目列表。为了获得与直接使用NavigationLink相同的用户界面,我们提供了一个自定义的onNavigation视图修饰符,该修饰符在选择项目时执行闭包。在它的闭包中,我们调用视图模型上的一个方法,该方法将调用转发给协调器:
class ListViewModel: ObservableObject, Identifiable {
@Published var items = [ListItem]()
private unowned let coordinator: CoordinatorObject
init(coordinator: CoordinatorObject) {
self.coordinator = coordinator
}
func open(_ item: ListItem) {
coordinator.open(item)
}
}
正如我们在上面看到的,Coordinator将通过创建一个新的视图模型并将其分配给它的detailViewModel属性来改变它的状态。因此,执行转场地。
❓ Why so complicated?
你可能会问自己,为什么我们要涉及所有这些组件来实现同一个目标,而这个目标在SwiftUI的单个视图中就可以实现。原因是我们想让我们的单个场景(在本例中是ListView和DetailView)完全独立于它们的视图上下文。 例如,我们希望能够改变从ListView到DetailView使用sheet代替push-而不实际接触ListView。此外,我们不想在ListViewModel中创建DetailView的ViewModel,因为这会在两者之间创建依赖关系,并使测试变得非常复杂。现在,我们可以简单地替换CoordinatorObject来轻松地更改应用程序这部分中所有视图模型的创建。
TabView
标签视图很容易理解,所以,让我们直接进入代码,从CoordinatorObject开始:
enum CoordinatorTab {
case one
case two
}
class CoordinatorObject: ObservableObject {
@Published var tab = CoordinatorTab.one
@Published var tabOneViewModel: TabOneViewModel!
@Published var tabTwoViewModel: TabTwoViewModel!
init() {
self.tabOneViewModel = TabOneViewModel(coordinator: self)
self.tabTwoViewModel = TabTwoViewModel(coordinator: self)
}
func switchToTabOne() {
self.tab = .one
}
}
同样,我们在coordinator object中有不同的view models和一个tab属性,并将它们封装在@Published-property包装器中。我们可以通过简单地对选项卡属性进行赋值来切换选项卡,正如您在switchToTabOne方法中看到的那样。 但仍然存在一个问题:视图如何利用这些属性?很简单:
struct CoordinatorView: View {
@ObservedObject var object: CoordinatorObject
var body: some View {
TabView(selection: $object.tab) {
Tab1View(viewModel: object.tabOneViewModel)
.tabItem { /* ... */ }
.tag(CoordinatorTab.one)
Tab2View(viewModel: object.tabTwoViewModel)
.tag(CoordinatorTab.two)
.tabItem { /* ... */ }
}
}
}
因为我们不想让Tab1View和Tab2View知道它们的视图上下文,所以我们也在coordinator view中添加了tab和tabItem,而不是它们的body属性。
Sheets & Popovers
到现在为止,你应该熟悉我们的模式-对sheets and popovers窗口来说是一样的。我们首先创建一个可观察的CoordinatorObject,带有相应的view model和一个触发工作表(或弹窗)的方法:
class CoordinatorObject: ObservableObject {
@Published var sheetViewModel: SheetViewModel?
func openSheet(_ info: SheetInformation) {
self.sheetViewModel = SheetViewModel(info: info)
}
}
就像NavigationViews一样,表单和弹出窗口是通过视图修改器创建的:
struct CoordinatorView: View {
@ObservedObject var object: CoordinatorObject
var body: some View {
Text("Hello, World!")
.sheet(item: $object.sheetViewModel) {
SomeSheet(viewModel: $0)
}
}
}
特别是在弹窗的情况下,这些视图修改器的位置(在本例中是.sheet修改器)是特别重要的,因为它定义了弹窗的源视图(即它的箭头指向哪里)。因此,它需要在场景视图本身内创建。
有两种方法可以处理这种情况:
-
我们将coordinator object注入到view中,包装为@ObservedObject——就像我们在coordinator view中做的那样。这样,视图就可以观察协调器的状态,并在设置相应属性时触发弹出窗口。
-
我们将一个视图修饰符注入到视图中,在弹出窗口应该在的地方使用。这有点棘手,这就是为什么我们不会在本文中详细解释这种方法的原因。但是,如果您是一位经验丰富的开发人员,可以在示例应用程序中查看我们的解决方案。
对于许多SwiftUI开发人员(尤其是初学者)来说,第一个解决方案可能要简单得多,但它创建了一个从场景视图到协调器的依赖关系,这正是我们在coordinators试图避免的。这就是为什么我们绝对推荐使用第二种解决方案,如果你觉得舒服的话。
不要试图通过场景的 view model 来访问coordinator’s状态——我们尝试过,但没能以合理的方式实现。