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留下的空白。