1. Introduction

好的,所以我们必须做一些管道,以适当地在我们的每个视图中得到我们的全局应用状态,但做这项工作的好处是,现在计数值将持续在所有屏幕上。 我们可以深度探讨计数器,更改它,回到主屏幕,再深度探讨,一切都恢复到以前的状态。 所以我们用SwiftUI中的@ObjectBinding只需要很少的工作就实现了持久性。

2. The prime checking modal

现在我们知道了如何在视图中表达状态,如何让视图对状态的变化做出反应,甚至如何在整个应用程序中持久化状态,让我们在应用程序中构建另一个视图。我们来做质数检查模态。当你点击“这是质数吗?”按钮,它会显示一个标签,让你知道当前计数器是否为素数,它还会给你一个按钮,用于从收藏列表中保存或删除该数字。

让我们回顾一下视图是什么样子的。当我们询问计数器视图一个数字是否为质数时,我们显示一个模态,告诉用户这个数字是否为质数,如果是,我们为用户提供从他们喜欢的质数中添加或删除这个数字的能力。

Modals在SwiftUI中通过在视图中设置presentation信息来呈现:

.presentation(<#T##modal: Modal?##Modal?#>)

Correction
这一集是在Xcode 11 beta 3中录制的,并且在beta 4和Xcode的后续版本中对演示api进行了更改。模态表示API是在一些称为sheet的视图修饰器方法中捕获的,这些方法在给定Binding的状态下显示并隐藏视图。

如果你在这里提供一个空值,什么也不会发生,如果你提供一个Modal值,它会在你的当前视图中显示那个Modal。然后要dismiss modal,你必须把nil放回这个函数。

所以,听起来我们需要一些状态来跟踪这个modal什么时候应该显示和隐藏。但是我们必须决定是否通过@State属性将其作为本地状态使用,或者是否要将其添加到全局AppState中。可能有一个用例需要在全局级别上获得这些信息,比如我们可能想在呈现模态时执行一些操作,或者我们想支持深入链接到这个模态,在这种情况下,我们想要将这些信息添加到AppState。然而,我们目前还没有使用它,所以我们将它建模为本地状态:

@State var isPrimeModalShown: Bool = false

然后我们可以使用这个值来决定要把什么交给.presentation模态:

.presentation(
  self.isPrimeModalShown
    ? Modal(Text("I don't know if \(self.state.count) is prime"))
    : nil
)

如果我们运行这个,什么也不会发生,因为我们还没有修改isprimemodalshow以使模态显示和隐藏。为了让它显示出来,我们只需挂钩到按钮的动作:

Button(action: { self.isPrimeModalShown = true }) {

如果我们运行这个,它似乎是工作的,但我们会发现我们有一个错误。我们可以点击按钮使模态出现,然后隐藏模态,但当我们改变计数器(或视图中的任何状态)时,模态会突然返回。为什么?

好吧,我们还没有将isprimemodalshow布尔值重置为false,所以下一次这个视图渲染SwiftUI会认为它需要呈现另一个模态。 重置这个状态很容易,我们可以挂钩到Modal值的onDismiss行为:

.presentation(
  self.isPrimeModalShown
    ? Modal(
      Text("I don't know if \(self.state.count) is prime"),
      onDismiss: { self.isPrimeModalShown = false }
      )
    : nil
)

这将确保布尔值被重置,现在当我们dismiss模态时,它保持dismissed状态。

所以我们现在正确地捕捉了模态的状态,但是模态没有任何有用的信息。目前,我们正在显示一个简单的文本视图,但我们想显示一些相当复杂的东西:一个VStack与一个文本视图和按钮内部,以及一些如何渲染这些视图的逻辑。由于这个视图的复杂性,最好创建一个全新的类型来封装它的逻辑:

struct IsPrimeModalView: View {
  var body: some View {
    Text("I don't know if \(self.state.count) is prime")
  }
}

然后我们可以更新我们的演示来使用这个视图:

.presentation(
  self.isPrimeModalShown
    ? Modal(
      IsPrimeModalView(),
      onDismiss: { self.isPrimeModalShown = false }
      )
    : nil
)

🛑 Value of type ‘IsPrimeModalView’ has no member ‘state’

现在我们需要将状态引入到模态中。

struct IsPrimeModalView: View {
  @ObjectBinding var state: AppState
  var body: some View {
    Text("I don't know if \(self.state.count) is prime")
  }
}

并传递给它的初始化式。

.presentation(
  self.isPrimeModalShown
    ? Modal(
      IsPrimeModalView(state: self.state),
      onDismiss: { self.isPrimeModalShown = false }
      )
    : nil
)

现在一切都像以前一样构建,但我们可以单独关注子视图。

模态视图的布局很简单,让我们来做一些基本的准备, 一组垂直堆叠的视图,包括告诉我们一个数字是否是质数的文本,还有一个按钮,可以让我们从喜爱的素数列表中添加和删除素数:

struct IsPrimeModalView: View {
  @ObjectBinding var state: AppState
  var body: some View {
    VStack {
      Text("I don't know if \(self.state.count) is prime")
      Button(action: {}) {
        Text("Save/remove to/from favorite primes")
      }
    }
  }
}

这里有一些我们想要联系起来的逻辑。 为了自定义文本,我们需要知道数字是否是质数。让我们引入一个方便的isPrime helper函数:

private func isPrime (_ p: Int) -> Bool {
  if p <= 1 { return false }
  if p <= 3 { return true }
  for i in 2...Int(sqrtf(Float(p))) {
    if p % i == 0 { return false }
  }
  return true
}

使用这个值,我们可以轻松地更改文本框的内容,

if isPrime(self.state.count) {
  Text("\(self.state.count) is prime 🎉")
} else {
  Text("\(self.state.count) is not prime :(")
}

当我们快速运行时,根据数字是否是质数,我们会在模态中显示不同的文本。

接下来我们要弄清楚如何处理这个按钮。如果这个计数值不是质数,它应该不会显示出来,所以我们至少可以将它移到if的第一个分支中:

if isPrime(self.state.count) {
  Text("\(self.state.count) is prime 🎉")
  Button(action: {}) {
    Text("Save/remove to/from favorite primes")
  }
} else {
  Text("\(self.state.count) is not prime :(")
}

然后我们应该根据质数是否在用户收藏列表中来改变标签和操作。但是最喜欢的名单是什么呢?我们还没有在应用程序状态中捕捉到喜爱素数列表的概念。所以让我们把它加起来!

我们回到AppState类,添加一个数组字段来保存所有用户最喜欢的质数,然后重写didSet,这样我们就可以通知感兴趣的用户发生变化:

var favoritePrimes: [Int] = [] {
  didSet { self.didChange.send() }
}

Correction
这一集是用Xcode 11 beta 3录制的。在后面的beta版本中,SwiftUI的BindableObject协议被弃用,取而代之的是引入到Combine框架中的ObservableObject协议。该协议利用了ObservableObjectPublisher的objectWillChange属性,该属性在对模型进行任何更改之前(而不是之后)被ping通。因此,应该使用willSet而不是didSet:

var favoritePrimes: [Int] = [] {
  willSet { self.objectWillChange.send() }
}

更好的是,我们可以通过使用@Published属性包装器来完全删除这个样板文件:

@Published var favoritePrimes: [Int] = []

每当我们向BindableObject添加state时,这种舞蹈总是必要的:我们需要记住进入didSet并通过调用它的send方法ping didChange。

现在我们可以访问favorites数组了,我们可以实现必要的逻辑:

if self.state.favoritePrimes.contains(self.state.count) {
  Button(action: {}) {
    Text("Remove from favorite primes")
  }
} else {
  Button(action: {}) {
    Text("Save to favorite primes")
  }
}

接下来,我们如何连接按钮的动作来完成这项工作? 去掉质数很简单。标准库中用于删除的API接受一个谓词,用于查找你想要删除的所有值:

Button(action: { self.state.favoritePrimes.removeAll(where: { $0 == self.state.count }) }) {

加法甚至更简单。我们只需要将当前的count值添加到数组中:

Button(action: { self.state.favoritePrimes.append(self.state.count) }) {

现在,当我们运行应用程序时,当我们向收藏夹添加或删除一个质数时,按钮文本就会切换。正如我们通过重新调用模态所看到的,状态也会持续存在。

3. Adding a side effect

我们现在应该有一个完整的功能模态! 我们可以在收藏列表中添加和删除质数,UI就会自动更新。

让我们将这个应用程序的复杂性提高一个档次。 屏幕上有一个按钮可以计算第n个质数,其中n是计数器的值。做这项工作可能会产生相当高的计算成本,而我们的isPrime助手目前还非常幼稚。代替弄清楚如何使这些东西更有效,并在本地完成所有的逻辑,让我们利用一个API,可以很容易地为我们回答这个问题。有一个叫Wolfram Alpha的服务,它是一个强大的科学计算平台。

我有一些与Wolfram API交互的简单库代码。它只是一些对API返回的数据建模的结构:

struct WolframAlphaResult: Decodable {
  let queryresult: QueryResult

  struct QueryResult: Decodable {
    let pods: [Pod]

    struct Pod: Decodable {
      let primary: Bool?
      let subpods: [SubPod]

      struct SubPod: Decodable {
        let plaintext: String
      }
    }
  }
}

作为一个函数,它接受一个查询字符串,发送它到Wolfram Alpha API,试图解码json数据到我们的结构,并调用一个回调的结果:

func wolframAlpha(query: String, callback: @escaping (WolframAlphaResult?) -> Void) -> Void {
  var components = URLComponents(string: "https://api.wolframalpha.com/v2/query")!
  components.queryItems = [
    URLQueryItem(name: "input", value: query),
    URLQueryItem(name: "format", value: "plaintext"),
    URLQueryItem(name: "output", value: "JSON"),
    URLQueryItem(name: "appid", value: wolframAlphaApiKey),
  ]

  URLSession.shared.dataTask(with: components.url(relativeTo: nil)!) { data, response, error in
    callback(
      data
        .flatMap { try? JSONDecoder().decode(WolframAlphaResult.self, from: $0) }
    )
    }
    .resume()
}

有了这个辅助函数,我们可以做一个更具体的API请求,一个请求Wolfram Alpha的第n个素数:

func nthPrime(_ n: Int, callback: @escaping (Int?) -> Void) -> Void {
  wolframAlpha(query: "prime \(n)") { result, response, error in
    callback(
      result
        .flatMap {
          $0.queryresult
            .pods
            .first(where: { $0.primary == .some(true) })?
            .subpods
            .first?
            .plaintext
        }
        .flatMap(Int.init)
    )
  }
}

我们可以通过查询第一千个质数兜一圈。

nthPrime(1_000) { p in print(p) }
// 7919

利用这个API,我们甚至可以查询第100万个质数,这在本地执行是非常昂贵的计算成本。

nthPrime(1_000_000) { p in print(p) }
// 15485863

我们该怎么用这个呢?让我们用语言来解释一下我们要做什么。当我们点击“第n个质数是多少?”按钮,我们想执行这个API请求,处理结果,然后显示一个警告。所以在我们进入所有这些之前,让我们看看警报是如何显示的。

警报的执行与模态非常相似,您可以使用.presentation方法指定显示警报的条件,并提供一个自定义视图来表示警报。然而,它需要一个显式的Binding值来控制何时显示和解除警报,而不是像我们对modal所做的那样采用一个可选的alert值。这个API有两个版本:

.presentation(<#T##isShown: Binding<Bool>##Binding<Bool>#>, alert: <#T##() -> Alert#>)
.presentation(<#T##data: Binding<Identifiable?>##Binding<Identifiable?>#>, alert: <#T##(Identifiable) -> Alert#>)

Correction
这一集是在Xcode 11 beta 3中录制的,并且在beta 4和Xcode的后续版本中对演示api进行了更改。上面的api已经被重命名为alert(ispresentated:content:)和alert(item:content:)。

我们可以提供一个布尔值Binding,只要绑定变成truealert显示,当它是falsealert消失,或者我们可以提供绑定的一个可选的,这样当一个存在的值时alert显示,当它是nilalert消失。

我们将使用后一种API,获得Binding值的最简单方法是引入一些状态,通过本地@State值或持久化@ObjectBinding值。由于显示和隐藏alert似乎是一个局部问题,我们很可能不需要从其他屏幕访问,让我们介绍一些@State:

@State var alertNthPrime: Int?

然后基于这个值,我们可以显示一个警告:

.presentation(self.$alertNthPrime) { n in
  Alert(
    title: Text("The \(ordinal(self.state.count)) prime is \(n)"),
    dismissButton: Alert.Button.default(Text("Ok"))
  )
}

注意,我们使用$alertNthPrime是为了传递alertNthPrime的绑定,而不仅仅是普通的布尔值。

当这个状态值变成一个诚实的整数时,闭包将使用该整数执行,我们可以构造一个警报值,这个警报将显示给用户。

现在的问题是:我们如何设置状态的值? 在点击按钮之后,我们想要向Wolfram Alpha发出一个API请求,当我们得到一个响应时,就会显示带有返回结果的警报。 看起来我们需要回到我们的" What 's the nth prime "按钮并实现它的动作:

Button(action: {
  nthPrime(self.state.count) { prime in
    self.alertNthPrime = prime
  }
}) {
  Text("What's the \(ordinal(self.state.count)) prime?")
}

如果我们运行这个,我们会看到,当我们点击按钮时,有一个短暂的暂停,而网络请求正在作出,然后最终我们得到警报。注意,当用户解除警报时,SwiftUI负责将该绑定重置为nil。

4. The favorites list

现在我们有了一个比较复杂的应用程序。我们在整个应用程序中管理和持久化状态,我们在呈现中添加微妙的逻辑,现在我们还添加了一个与外部服务通信的副作用。但我们需要进一步提高复杂性,因为目前这基本上只是一个单一屏幕的应用程序。这个屏幕有很多内容,但是为了演示它是多么强大,我们可以在整个应用程序中共享状态,我们应该构建另一个需要访问该状态的屏幕。所以,让我们构建一个最终的屏幕,可以显示所有我们喜欢的质数,并添加删除不再喜欢的质数的功能。

让我们回顾一下这个屏幕是什么以及如何到达那里。在根导航视图中,我们可以深入到收藏质数列表,这个列表将被我们收藏的任何质数填充,我们将能够删除不再喜欢的质数。

让我们从一些小步骤开始,然后粘贴脚手架来创建一个新视图。

struct FavoritePrimes: View {
  @ObjectBinding var state: AppState

  var body: some View {
    EmptyView()
      .navigationBarTitle(Text("Favorite Primes"))
  }
}

然后我们可以将这个视图连接到根内容视图:

NavigationLink(destination: FavoritePrimes(state: self.state)) {
  Text("Favorite primes")
}

现在我们想要什么样的质数呢?它将是一个任意行数的列表,每一行对应一个我们喜欢的质数。所以我们可能会这样做:

var body: some View {
  List {
    self.state.favoritePrimes.map { prime in
      Text("\(prime)")
    }
  }
    .navigationBarTitle(Text("Favorite Primes"))
}

然而,SwiftUI目前不允许这种视图构造。 相反,还有另一个视图包装器,类似于List包装器,它允许我们指定列表的所有行。它叫做ForEach,它是这样使用的:

var body: some View {
  List {
    ForEach(self.state.favoritePrimes) { prime in
      Text("\(prime)")
    }
  }
    .navigationBarTitle(Text("Favorite Primes"))
}

现在我们加上几个质数,回到这个屏幕我们会看到所有的质数。让我们添加删除功能。这可以通过在ForEach元素中添加onDelete处理程序来实现:

.onDelete(perform: { indexSet in
  for index in indexSet {
    self.state.favoritePrimes.remove(at: index)
  }
})

现在我们可以添加一些喜欢的质数,回到我们的最爱列表,删除那些不再是我们的最爱,并仔细检查所有的状态同步让我们回到柜台查看和添加'重新成为一个最喜欢的。

5. Next time: what’s the point?

我们现在有了一个在SwiftUI中构建的比较复杂的应用程序。这真的很神奇。我们绝对不可能在使用UIKit的时间内构建这个应用。 在这一过程中,可能会出现许多需要实现的协议和需要建立的委托,而且可能还会引入大量的bug。但尽管这很酷,在每个point - free主题的结尾,我们都喜欢问这样一个问题:“重点是什么?”“为了把事情弄清楚,这样我们就能从树上看到森林。这一集已经相当实用了,但也有一些非常重要的教训需要吸取。

我们想要做的是列出所有我们喜欢SwiftUI的东西,以及所有似乎还没有实现的东西。最后,我们将探索我们可以做些什么来弥补SwiftUI留下的空白。