第7章:观察对象¶
在上一章中,你管理了数值的流动,以实现用户在浏览和使用你的应用程序时期望的大部分功能。在这一章中,你将管理你的应用程序的一些数据对象。你将使用一个Timer发布者,并让一些视图访问HistoryStore作为一个EnvironmentObject。
显示/隐藏定时器¶
本节中你将学习的技能:使用Timer发布者;显示和隐藏子视图。
这是你的下一个功能。在ExerciseView中,点击Start Exercise会显示一个倒计时的计时器;在计时器达到0之前,Done按钮是无效的。 点击Done则会隐藏计时器。在这一节中,你将创建一个TimerView,然后用一个布尔标志在ExerciseView中显示或隐藏它。
使用一个真正的定时器¶
你的应用程序目前使用了一个style: .timer的Text视图。这个计时器倒计时很好,但随后它又开始计数,并一直持续下去。你对它没有任何控制。你不能停止它。你甚至不能检查它何时达到零。
Swift有一个Timer类,该类方法可以创建一个Timer发布者。Publisher是苹果新的Combine并发框架的基础,而Timer发布者比普通的Timer更容易操作。
Note
关于这个框架的完整内容,请查看我们的书《Combine:用Swift进行异步编程》一书,网址是https://bit.ly/3sW1L3I。
➤ 继续上一章的项目,或者打开本章起始文件夹中的项目。
➤ 创建一个新的SwiftUI视图文件并将其命名为TimerView.swift。
➤ 用以下内容替换View和PreviewProvider结构:
struct TimerView: View {
@State private var timeRemaining = 3 // 1
@Binding var timerDone: Bool // 2
let timer = Timer.publish( // 3
every: 1,
on: .main,
in: .common)
.autoconnect() // 4
var body: some View {
Text("\(timeRemaining)") // 5
.font(.system(size: 90, design: .rounded))
.padding()
.onReceive(timer) { _ in // 6
if self.timeRemaining > 0 {
self.timeRemaining -= 1
} else {
timerDone = true // 7
}
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView(timerDone: .constant(false))
.previewLayout(.sizeThatFits)
}
}
timeRemaining是计时器在每个练习中运行的秒数。通常情况下,这是30秒。但是在本节中你要实现的一个功能是禁用完成按钮,直到计时器达到零。你把timeRemaining设置得非常小,这样你在测试这个功能时就不必等待30秒。- 你将在
ExerciseView中设置开始练习按钮,以显示TimerView,传递给timerDone布尔标志的绑定,使Done按钮生效。当定时器到达0时,你将改变timerDone的值,但这个值不属于TimerView,所以它必须是一个Binding变量。 - 你调用类方法
Timer.publish(every:on:in:)创建一个Timer发布者,发布事件every1秒on主-用户界面-线程的运行循环in common模式。
Note
运行循环是iOS用于异步事件源处理的基础机制。
Timer发布者是一个ConnectablePublisher。在你明确地调用它的connect()方法之前,它不会在订阅时开始启动。在这里,你使用autoconnect()操作符,在你的Text视图订阅它时,立即连接发布者。- 实际的
TimerView以大的圆形系统字体显示timeRemaining,周围有填充物。 onReceive(_:perform:)修改器订阅Timer发布者并更新timeRemaining,只要它的值是正的。- 当
timeRemaining达到0时,它将timerDone设置为true。这使得ExerciseView中的Done按钮生效。
Note
onReceive(_:perform:)返回一个已发布的事件,但你的动作没有使用它,所以你用_确认它的存在。
显示定时器¶
➤ 在ExerciseView.swift中,将let interval: TimeInterval = 30,用下面的代码。
@State private var timerDone = false
@State private var showTimer = false
你将把$timerDone传递给TimerView,当定时器到达0时,它将被设置为true。你将用它来启用Done按钮。
而且,你将切换showTimer,就像你对showHistory和showSuccess所做的那样。
➤ 接下来,找到Text视图的定时器:
Text(Date().addingTimeInterval(interval), style: .timer)
.font(.system(size: 90))
上面有一个错误标志,因为你删除了interval属性。
➤ 用以下代码替换这个Text视图和font修改器:
if showTimer {
TimerView(timerDone: $timerDone)
}
当showTimer为true时,你就会调用TimerView,将一个绑定到State变量timerDone上。
➤ 然后,用下面的代码替换Button("Start Exercise") { }:
Button("Start Exercise") {
showTimer.toggle()
}
这就像你的其他按钮一样,切换一个布尔值来显示另一个视图。
启用Done按钮并隐藏定时器¶
➤ 现在,将这两行添加到Done按钮的动作中,在if-else的上方:
timerDone = false
showTimer.toggle()
如果启用了Done按钮,timerDone现在是true,所以你把它重置为false以禁用Done按钮。
另外,TimerView正在显示。这意味着showTimer目前是true,所以你把它切换回false,以隐藏TimerView。
➤ 接下来,在Button上添加这个修改器,在sheet(isPresented:)修改器上面:
.disabled(!timerDone)
当timerDone为false时,你将禁用Done按钮。
测试定时器和完成按钮¶
➤ 现在检查previews仍然显示最后的练习:
ExerciseView(selectedTab: .constant(3), index: 3)
这个练习页提供可见的反馈。它通过显示SuccessView来响应点选Done。
➤ 开始实时预览:

完成按钮被禁用。
➤ 点击Start Exercise,在计时器从三开始倒数时等待:

当计时器达到0时,就会启用Done按钮。
➤ 点击Done。

这是最后一个练习,所以出现了SuccessView。
➤ 点击Continue。

因为你正在预览ExerciseView,而不是ContentView,所以你返回到ExerciseView,而不是WelcomeView。
现在定时器被隐藏了,Done又被禁用了。
➤ 点开始练习,看到计时器又从3开始了。
调整用户界面¶
点击Start Exercise显示计时器,并将按钮和评级符号推到屏幕下方。点击Done又会把它们移上去。这么多的移动可能是不可取的,除非你认为这是一个合适的锻炼应用程序的feature。
为了阻止按钮和评级的下蹲,你要重新安排UI元素。
➤ 在ExerciseView.swift中,找到if showTimer {和Spacer()一行。用下面的代码替换这几行,以及它们之间的所有内容:
HStack(spacing: 150) {
Button("Start Exercise") { // Move buttons above TimerView
showTimer.toggle()
}
Button("Done") {
timerDone = false
showTimer.toggle()
if lastExercise {
showSuccess.toggle()
} else {
selectedTab += 1
}
}
.disabled(!timerDone)
.sheet(isPresented: $showSuccess) {
SuccessView(selectedTab: $selectedTab)
}
}
.font(.title3)
.padding()
if showTimer {
TimerView(timerDone: $timerDone)
}
Spacer()
RatingView(rating: $rating) // Move RatingView below Spacer
.padding()
你把按钮移到计时器上面,把RatingView(rating:)移到Spacer()下面。这留下了一个稳定的空间来显示和隐藏计时器。
➤ 运行实时预览。点Start Exercise,等待Done按钮,然后点它。计时器出现,然后消失。其他UI元素都没有移动。

还有最后一项功能要添加到你的应用程序中。这是Done按钮的另一项工作。
在历史中添加一个练习¶
本节将学习的技能:使用@ObservableObject和@EnvironmentObject来让子视图访问数据;类与结构。
这是最后一个功能。点击Done将这个练习添加到用户当天的历史记录中。你将把这个练习添加到今天的ExerciseDay对象的exercises数组中,或者你将创建一个新的ExerciseDay对象并把这个练习添加到它的数组。
检查你的应用程序,看看哪些视图需要访问HistoryStore,每个视图需要什么样的访问:

ContentView调用WelcomeView和ExerciseView。WelcomeView和ExerciseView调用HistoryView。ExerciseView改变HistoryStore,所以HistoryStore必须是State或Binding变量在ExerciseView。HistoryView只需要读取HistoryStore。WelcomeView和ExerciseView调用HistoryView,所以WelcomeView只需要读取HistoryStore的权限,这样它就可以把它传递给HistoryView。
不止一个视图需要访问HistoryStore,所以你需要一个单一的真相来源。有不止一种方法可以做到这一点。
上面的最后一个列表项是最不理想的。你将学习如何管理HistoryStore,以便它不必通过WelcomeView。
➤ 现在就把这个项目拷贝下来,用它来开始本章末尾的挑战。
创建一个可观察对象¶
为了解雇SuccessView,你使用了它的presentationMode环境属性。这是系统预定义的环境属性之一。你可以在一个视图上定义你自己的环境对象,并且它可以被该视图的任何子视图访问。你不需要把它作为一个参数来传递。任何需要它的子视图只需将它声明为一个属性。
所以如果你把HistoryStore变成一个EnvironmentObject,你就不必把它传给WelcomeView,这样WelcomeView就可以把它传给HistoryView。
要成为一个EnvironmentObject,HistoryStore必须符合ObservableObject协议。ObservableObject是一个发布者,就像Timer.publisher。
为了符合ObservableObject,HistoryStore必须是一个类,而不是一个结构。
Swift
结构和枚举是值类型。如果Person是一个结构,而你创建了Person对象audrey,那么audrey2 = audrey会创建一个audrey的单独副本。你可以改变audrey2的属性而不影响audrey。类是参考类型。如果Person是一个类,并且你创建了Person对象audrey,那么audrey2 = audrey会创建一个对同一audrey对象的引用。如果你改变了audrey2的一个属性,你也会改变audrey的那个属性。
在HistoryStore.swift中,将HistoryStore的前两行替换为以下内容:
class HistoryStore: ObservableObject {
@Published var exerciseDays: [ExerciseDay] = []
你使HistoryStore成为一个类而不是一个结构,然后使它符合ObservableObject协议。
你用@Published属性包装器来标记exerciseDays数组中的ExerciseDay对象。每当exerciseDays发生变化时,它就会向任何订阅者发布自己的信息,并且系统会重新绘制任何受影响的视图。
特别是,当ExerciseView向exerciseDays添加一个ExerciseDay时,HistoryView会被更新。
➤ 现在,向HistoryStore添加以下方法,在init()下面:
func addDoneExercise(_ exerciseName: String) {
let today = Date()
if today.isSameDay(as: exerciseDays[0].date) { // 1
print("Adding \(exerciseName)")
exerciseDays[0].exercises.append(exerciseName)
} else {
exerciseDays.insert( // 2
ExerciseDay(date: today, exercises: [exerciseName]),
at: 0)
}
}
你将在ExerciseView的Done按钮动作中调用这个方法。
exerciseDays的第一个元素的date是用户最近的锻炼日。如果today和这个date相同,你就把当前的exerciseName追加到这个exerciseDay的exercises数组中。- 如果
today是新的一天,你创建一个新的ExerciseDay对象,并将其插入exerciseDays数组的开头。
Note
isSameDay(as:)在DateExtension.swift中定义。
➤ 现在要修复Preview Content/HistoryStoreDevData.swift中的错误,删除mutating:
func createDevData() {
当HistoryStore是一个结构时,你必须把这个方法标记为mutating。你不能对定义在类中的方法使用mutating。
Swift
结构往往是恒定的,所以你必须把任何改变属性的方法标记为mutating。如果你把一个类中的方法标记为mutating,Xcode会标记一个错误。参见第15章"结构、类和协议"以进一步讨论引用和值类型。
使用一个环境对象¶
现在,你需要将HistoryStore设置为ExerciseView的父视图中的EnvironmentObject。ContentView包含TabView,它调用ExerciseView,所以你要在TabView上创建EnvironmentObject。
➤ 在ContentView.swift中,给TabView(selection:)上面的.tabViewStyle(PageTabViewStyle(indexDisplayMode:.never))添加这个修改器:
.environmentObject(HistoryStore())
你初始化HistoryStore并将其作为EnvironmentObject传递给TabView。这使得它对TabView的子视图树中的所有视图可用,包括HistoryView。
➤ 在HistoryView.swift中,用这个属性替换let history = HistoryStore():
@EnvironmentObject var history: HistoryStore
你不希望在这里创建另一个HistoryStore对象。相反,HistoryView可以直接访问history,而不需要把它作为一个参数传递。
➤ 接下来,在previews中的HistoryView(showHistory:)添加这个修改器:
.environmentObject(HistoryStore())
你必须告诉previews关于这个EnvironmentObject,否则它将崩溃,没有任何关于出错的有用信息。
➤ 在ExerciseView.swift中,给ExerciseView添加同样的属性:
@EnvironmentObject var history: HistoryStore
ExerciseView获得对HistoryStore的读写权限,而不需要将history从ContentView传递给ExerciseView作为参数。
➤ 将ExerciseView(selectedTab:index:)在previews中替换为以下内容:
ExerciseView(selectedTab: .constant(0), index: 0)
.environmentObject(HistoryStore())
你会预览第一个练习,你把HistoryStore作为EnvironmentObject附加上去,就像在HistoryView.swift中一样。
➤ 现在在Done按钮的动作闭合的顶部添加这一行:
history.addDoneExercise(Exercise.exercises[index].exerciseName)
你把这个练习的名字添加到HistoryStore中。
➤ 运行实时预览,然后点击历史记录,查看已经存在的内容:

➤ 关闭HistoryView,然后点开始练习。当Done被启用时,点它。因为你正在预览ExerciseView,它不会进入下一个练习。
➤ 现在再点History:

这是你的新ExerciseDay,有这个练习!
你的应用程序现在运行得很好,具有所有预期的导航功能。但你仍然需要保存用户的评分和历史记录,以便在退出和重启你的应用程序后它们仍然存在。然后,你终于可以让你的应用程序看起来很漂亮了。
挑战¶
为了体会@EnvironmentObject对这个功能的作用,用State和Binding实现它。
挑战:使用@State和@Binding来为HistoryStore添加练习¶
- 从你把
HistoryStore改为ObservableObject之前的项目副本开始。或者打开挑战文件夹中的启动项目。 - 通过注释
WelcomeView、ExerciseView和HistoryView中的previews来节省时间和精力。只要把ContentView的预览钉住,这样你就可以在编辑任何视图文件时检查你的工作。 - 在
ContentView中初始化history并将其传递给WelcomeView和ExerciseView。在你需要的地方使用State和绑定。 - 将
history从WelcomeView和ExerciseView传递到HistoryView。在HistoryView中,将let history = HistoryStore()改为let history: HistoryStore。 - 在
HistoryStore中添加addDoneExercise(_ exerciseName:)作为mutating方法,并在ExerciseView中的Done按钮的动作中调用它。
我的解决方案在本章的challenge/final文件夹中。
关键点¶
- 通过订阅由
Timer.publish(every:tolerance:on:in:options:)创建的Timer发布者来创建一个定时器。 @Binding声明对另一个视图拥有的@State变量的依赖性。@EnvironmentObject声明对一些共享数据的依赖,例如符合ObservableObject的引用类型。- 使用
ObservableObject作为@EnvironmentObject来让子视图访问数据而无需传递参数。