跳转至

第7章:观察对象

在上一章中,你管理了数值的流动,以实现用户在浏览和使用你的应用程序时期望的大部分功能。在这一章中,你将管理你的应用程序的一些数据对象。你将使用一个Timer发布者,并让一些视图访问HistoryStore作为一个EnvironmentObject

显示/隐藏定时器

本节中你将学习的技能:使用Timer发布者;显示和隐藏子视图。

这是你的下一个功能。在ExerciseView中,点击Start Exercise会显示一个倒计时的计时器;在计时器达到0之前,Done按钮是无效的。 点击Done则会隐藏计时器。在这一节中,你将创建一个TimerView,然后用一个布尔标志在ExerciseView中显示或隐藏它。

使用一个真正的定时器

你的应用程序目前使用了一个style: .timerText视图。这个计时器倒计时很好,但随后它又开始计数,并一直持续下去。你对它没有任何控制。你不能停止它。你甚至不能检查它何时达到零。

Swift有一个Timer类,该类方法可以创建一个Timer发布者。Publisher是苹果新的Combine并发框架的基础,而Timer发布者比普通的Timer更容易操作。

Note

关于这个框架的完整内容,请查看我们的书《Combine:用Swift进行异步编程》一书,网址是https://bit.ly/3sW1L3I

➤ 继续上一章的项目,或者打开本章起始文件夹中的项目。

➤ 创建一个新的SwiftUI视图文件并将其命名为TimerView.swift

➤ 用以下内容替换ViewPreviewProvider结构:

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)
  }
}
  1. timeRemaining是计时器在每个练习中运行的秒数。通常情况下,这是30秒。但是在本节中你要实现的一个功能是禁用完成按钮,直到计时器达到零。你把timeRemaining设置得非常小,这样你在测试这个功能时就不必等待30秒。
  2. 你将在ExerciseView中设置开始练习按钮,以显示TimerView,传递给timerDone布尔标志的绑定,使Done按钮生效。当定时器到达0时,你将改变timerDone的值,但这个值不属于TimerView,所以它必须是一个Binding变量。
  3. 你调用类方法Timer.publish(every:on:in:)创建一个Timer发布者,发布事件every1秒on主-用户界面-线程的运行循环in common模式。

Note

运行循环是iOS用于异步事件源处理的基础机制。

  1. Timer发布者是一个ConnectablePublisher。在你明确地调用它的connect()方法之前,它不会在订阅时开始启动。在这里,你使用autoconnect()操作符,在你的Text视图订阅它时,立即连接发布者。
  2. 实际的TimerView以大的圆形系统字体显示timeRemaining,周围有填充物。
  3. onReceive(_:perform:)修改器订阅Timer发布者并更新timeRemaining,只要它的值是正的。
  4. 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,就像你对showHistoryshowSuccess所做的那样。

➤ 接下来,找到Text视图的定时器:

Text(Date().addingTimeInterval(interval), style: .timer)
  .font(.system(size: 90))

上面有一个错误标志,因为你删除了interval属性。

➤ 用以下代码替换这个Text视图和font修改器:

if showTimer {
  TimerView(timerDone: $timerDone)
}

showTimertrue时,你就会调用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)

timerDonefalse时,你将禁用Done按钮。

测试定时器和完成按钮

➤ 现在检查previews仍然显示最后的练习:

ExerciseView(selectedTab: .constant(3), index: 3)

这个练习页提供可见的反馈。它通过显示SuccessView来响应点选Done

➤ 开始实时预览:

ExerciseView with disabled Done button

完成按钮被禁用。

➤ 点击Start Exercise,在计时器从三开始倒数时等待:

ExerciseView with enabled Done button

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

➤ 点击Done

Tap Done to show SuccessView.

这是最后一个练习,所以出现了SuccessView

➤ 点击Continue

ExerciseView with disabled Done button

因为你正在预览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元素都没有移动。

Show/hide timer without moving other UI elements.

还有最后一项功能要添加到你的应用程序中。这是Done按钮的另一项工作。

在历史中添加一个练习

本节将学习的技能:使用@ObservableObject@EnvironmentObject来让子视图访问数据;类与结构。

这是最后一个功能。点击Done将这个练习添加到用户当天的历史记录中。你将把这个练习添加到今天的ExerciseDay对象的exercises数组中,或者你将创建一个新的ExerciseDay对象并把这个练习添加到它的数组。

检查你的应用程序,看看哪些视图需要访问HistoryStore,每个视图需要什么样的访问:

HistoryStore view tree

  • ContentView调用WelcomeViewExerciseView
  • WelcomeViewExerciseView调用HistoryView
  • ExerciseView改变HistoryStore,所以HistoryStore必须是StateBinding变量在ExerciseView
  • HistoryView只需要读取HistoryStore
  • WelcomeViewExerciseView调用HistoryView,所以WelcomeView只需要读取HistoryStore的权限,这样它就可以把它传递给HistoryView

不止一个视图需要访问HistoryStore,所以你需要一个单一的真相来源。有不止一种方法可以做到这一点。

上面的最后一个列表项是最不理想的。你将学习如何管理HistoryStore,以便它不必通过WelcomeView

➤ 现在就把这个项目拷贝下来,用它来开始本章末尾的挑战。

创建一个可观察对象

为了解雇SuccessView,你使用了它的presentationMode环境属性。这是系统预定义的环境属性之一。你可以在一个视图上定义你自己的环境对象,并且它可以被该视图的任何子视图访问。你不需要把它作为一个参数来传递。任何需要它的子视图只需将它声明为一个属性。

所以如果你把HistoryStore变成一个EnvironmentObject,你就不必把它传给WelcomeView,这样WelcomeView就可以把它传给HistoryView

要成为一个EnvironmentObjectHistoryStore必须符合ObservableObject协议。ObservableObject是一个发布者,就像Timer.publisher

为了符合ObservableObjectHistoryStore必须是一个类,而不是一个结构。

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发生变化时,它就会向任何订阅者发布自己的信息,并且系统会重新绘制任何受影响的视图。

特别是,当ExerciseViewexerciseDays添加一个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)
  }
}

你将在ExerciseViewDone按钮动作中调用这个方法。

  1. exerciseDays的第一个元素的date是用户最近的锻炼日。如果today和这个date相同,你就把当前的exerciseName追加到这个exerciseDayexercises数组中。
  2. 如果today是新的一天,你创建一个新的ExerciseDay对象,并将其插入exerciseDays数组的开头。

Note

isSameDay(as:)DateExtension.swift中定义。

➤ 现在要修复Preview Content/HistoryStoreDevData.swift中的错误,删除mutating

func createDevData() {

HistoryStore是一个结构时,你必须把这个方法标记为mutating。你不能对定义在类中的方法使用mutating

Swift

结构往往是恒定的,所以你必须把任何改变属性的方法标记为mutating。如果你把一个类中的方法标记为mutatingXcode会标记一个错误。参见第15章"结构、类和协议"以进一步讨论引用和值类型。

使用一个环境对象

现在,你需要将HistoryStore设置为ExerciseView的父视图中的EnvironmentObjectContentView包含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的读写权限,而不需要将historyContentView传递给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中。

➤ 运行实时预览,然后点击历史记录,查看已经存在的内容:

History: before

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

➤ 现在再点History

History: after

这是你的新ExerciseDay,有这个练习!

你的应用程序现在运行得很好,具有所有预期的导航功能。但你仍然需要保存用户的评分和历史记录,以便在退出和重启你的应用程序后它们仍然存在。然后,你终于可以让你的应用程序看起来很漂亮了。

挑战

为了体会@EnvironmentObject对这个功能的作用,用StateBinding实现它。

挑战:使用@State@Binding来为HistoryStore添加练习

  • 从你把HistoryStore改为ObservableObject之前的项目副本开始。或者打开挑战文件夹中的启动项目。
  • 通过注释WelcomeViewExerciseViewHistoryView中的previews来节省时间和精力。只要把ContentView的预览钉住,这样你就可以在编辑任何视图文件时检查你的工作。
  • ContentView中初始化history并将其传递给WelcomeViewExerciseView。在你需要的地方使用State和绑定。
  • historyWelcomeViewExerciseView传递到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来让子视图访问数据而无需传递参数。