第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
发布者,发布事件every
1秒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
来让子视图访问数据而无需传递参数。