在使用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)”,因为它们的工作是控制这些场景之间的数据流,定义在某一事件的基础上进行哪种转场过渡,并创建新的场景。

avatar

通过结合MVVM(Model-View-ViewModel)体系结构模式,我们可以清楚地分离 view components and business logic 之间的关注点。与其他应用程序架构一样,Model components是为应用程序的持久性、联网和计算能力而构建的。 ViewModelsModels中检索到的信息并将其转发给View组件,这些组件依次定义用户界面。 结合Coordinator模式,我们可以使用Coordinator在不同的视图模型之间交换信息,并定义转场逻辑。

How SwiftUI is Different

我们现在如何在SwiftUI中实现这一功能? 正如在介绍中提到的,我们不能简单地使用与UIKit中相同的架构。原因是SwiftUI遵循声明式方法。我们没有描述如何用长期存在的对象构建用户界面的过程,而是简单地根据应用程序的当前状态定义视图应该是什么样子。只要状态改变,就会重新创建视图,并相应地更新用户界面。因此,我们可以将SwiftUI视图定义为当前状态的函数,或者用数学方法:

avatar

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

avatar

因此,在SwiftUI中执行转场不像在UIKit中那样是一个简单的方法调用,这使得采用Coordinator更加困难。相反,我们需要考虑我们的coordinatorsUIKit中扮演什么角色,以及如何将这些独立的责任转移到SwiftUI

在UIKit中,Coordinator有以下职责:

  1. 它控制view的上下文,也就是说无论它是在UINavigationController中使用,还是在UITabBarController中使用。
  2. 它定义了当某些事件发生时执行哪些转场,比如呈现一个新的视图控制器或将其推到当前导航堆栈上。
  3. 它创建视图控制器并管理它们之间的内存交换(例如,通过创建新的视图模型)。

现在,让我们看看如何将这些责任转移到SwiftUI!


A Coordinator in SwiftUI

我们将使用MVVM方法(非常类似于之前的博客文章中的MVVM方法),因此每个视图将有一个符合ObservableObject的对应view model,用于定义view's state。但最疯狂的部分来了:不仅我们的viewview 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!

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)完全独立于它们的视图上下文。 例如,我们希望能够改变从ListViewDetailView使用sheet代替push-而不实际接触ListView。此外,我们不想在ListViewModel中创建DetailViewViewModel,因为这会在两者之间创建依赖关系,并使测试变得非常复杂。现在,我们可以简单地替换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修改器)是特别重要的,因为它定义了弹窗的源视图(即它的箭头指向哪里)。因此,它需要在场景视图本身内创建。

有两种方法可以处理这种情况:

  1. 我们将coordinator object注入到view中,包装为@ObservedObject——就像我们在coordinator view中做的那样。这样,视图就可以观察协调器的状态,并在设置相应属性时触发弹出窗口。

  2. 我们将一个视图修饰符注入到视图中,在弹出窗口应该在的地方使用。这有点棘手,这就是为什么我们不会在本文中详细解释这种方法的原因。但是,如果您是一位经验丰富的开发人员,可以在示例应用程序中查看我们的解决方案。

对于许多SwiftUI开发人员(尤其是初学者)来说,第一个解决方案可能要简单得多,但它创建了一个从场景视图到协调器的依赖关系,这正是我们在coordinators试图避免的。这就是为什么我们绝对推荐使用第二种解决方案,如果你觉得舒服的话。

不要试图通过场景的 view model 来访问coordinator’s状态——我们尝试过,但没能以合理的方式实现。

原文链接