跳转至

第6章:为你的应用程序添加功能

在上一章中,你对你的应用程序的数据进行了结构化处理,使其更有效率,更不容易出错。在这一章中,你将实现你的用户在浏览和使用你的应用程序时期望的大部分功能。现在,你需要管理你的应用程序的数据,使数值顺利地流经你的应用程序的视图和子视图。

管理你的应用程序的数据

SwiftUI有两个指导原则来管理数据如何流经你的应用程序。

  • Data access = dependency:在你的视图中读取一块数据会在该视图中创建一个对该数据的依赖性。每个视图都是其数据依赖的一个函数--它的输入或状态。
  • 单一真理源:视图读取的每一块数据都有一个真理源,这个真理源要么为视图所有,要么为视图外部。无论真理源在哪里,你都应该有一个单一的真理源。

数据流的工具

SwiftUI提供了几个工具来帮助你管理应用程序中的数据流。SwiftUI框架负责在视图应该出现时创建它们,并在它们所依赖的数据发生变化时更新它们。

属性包装器增强了属性的行为。SwiftUI特定的包装器,如@State@Binding@EnvironmentObject声明视图对属性所代表的数据的依赖性。

Some of the data flow in HIITFit

每个包装器表示不同的数据来源:

  • 一个@State属性是一个真理的来源。一个视图拥有它,并将它的值或引用,即所谓的绑定,传递给它的子视图。
  • 一个@Binding属性是对另一个视图所拥有的@State属性的引用。当其他视图传递给它一个绑定时,它就获得了它的初始值,使用$前缀。有了这个对真理之源的引用,子视图就可以改变该属性的值,这就改变了任何依赖该属性的视图的状态。
  • @EnvironmentObject声明对一些共享数据的依赖性--这些数据对应用程序的子树中的所有视图都是可见的。这是一种间接传递数据的便捷方式,而不是从父视图到子视图再到孙视图的数据传递,尤其是当中间的子视图不需要它时。

你将在第11章"理解属性包装器"中进一步了解这些以及其他的属性包装器。

导航TabView

本节将学习的技能:使用@State@Binding属性;钉住一个预览;在预览中添加@Binding参数。

这是你的第一个功能。设置TabView以使用tag值。当一个按钮改变了selectedTab的值时,TabView会显示该标签。

打开启动项目。它与前一章的最终无本地化项目相同。

给标签贴标签

➤ 在ContentView.swift中,向ContentView添加此属性:

@State private var selectedTab = 9

Note

你几乎总是把State属性标记为private,以强调它是由这个视图特别拥有和管理的。只有这个文件中的这个视图的代码可以直接访问它。一个例外是当App需要初始化ContentView时,所以它需要向其State属性传递值。在《Swift学徒》第18章"访问控制、代码组织与测试"bit.ly/37EUQDk中了解更多关于访问控制的信息。

selectedTab声明为ContentView@State属性,意味着ContentView拥有这个属性,它是这个值的唯一真实来源。

其他视图会使用selectedTab的值,有些会改变这个值以使TabView显示另一个页面。但是,你不会在其他视图中把它声明为state属性。

selectedTab的初始值是9,你将把它设置为欢迎页面的tag值。

➤ 现在用下面的代码替换ContentView的整个body闭包:

var body: some View {
  TabView(selection: $selectedTab) {
    WelcomeView(selectedTab: $selectedTab)  // 1
      .tag(9)  // 2
    ForEach(0 ..< Exercise.exercises.count) { index in
      ExerciseView(selectedTab: $selectedTab, index: index)
        .tag(index)  // 3
    }
  }
  .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}

Xcode抱怨说你传递了一个额外的参数,因为你还没有给WelcomeViewExerciseView添加selectedTab属性。你很快就会这样做的。

  1. 你把绑定的$selectedTab传递给WelcomeViewExerciseView,这样TabView就可以在他们改变其值时做出反应。
  2. 你用9作为WelcomeView的标签。
  3. Exercise.exercise中的索引来标记每个ExerciseView

➤ 在你去编辑WelcomeView.swiftExerciseView.swift之前,点击pin按钮,把ContentView的预览钉在上面:

Pin the preview of ContentView.

当你改变WelcomeView.swiftExerciseView.swift中的代码时,你就可以实时查看结果,而不需要回到ContentView.swift中。

向视图添加绑定项

➤ 现在,在ExerciseView.swift中,给ExerciseView添加这个属性,在let index: Int

@Binding var selectedTab: Int

你很快就会写代码让ExerciseView改变selectedTab的值,所以它不可能是一个普通的var selectedTab。视图是结构,这意味着你不能改变一个属性值,除非你用一个属性包装器,如@State@Binding标记它。

ContentView拥有selectedTab的真理源。你没有在ExerciseView中声明@State private var selectedTab,因为这将创建一个重复的真理源,你必须与ContentView中的selectedTab值保持同步。相反,你声明@Binding var selectedTab--对ContentView拥有的State变量的引用。

➤ 你需要更新previews,因为它创建了一个ExerciseView实例。像这样添加这个新参数:

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

你只是想让预览显示第二个练习,但你不能传递1作为selectedTab值。你必须传递一个Binding,这在像这样的独立情况下是很棘手的,因为你没有一个@State属性可以绑定到。幸运的是,SwiftUI提供了Binding类型方法constant(_:)来从一个常量值创建Binding

➤ 现在,在WelcomeView.swift中为WelcomeView添加相同的属性:

@Binding var selectedTab: Int

➤ 并在其previews中添加此参数:

WelcomeView(selectedTab: .constant(9))

➤ 现在你已经修复了错误,你可以在WelcomeView.swift中恢复预览:

WelcomeView preview with pinned ContentView preview

进展到第一个练习

接下来,你将实现欢迎页面的开始按钮动作,以显示第一个ExerciseView

➤ 在WelcomeView.swift中,用这个替换Button(action: { }) {

Button(action: { selectedTab = 0 }) {

➤ 现在为钉子的ContentView预览打开实时预览,然后点击开始。

Tap Get Started to show first exercise.

Note

你不能在WelcomeView预览中预览这个动作,因为它不包括ExerciseView。点击开始不会有任何进展。

你已经用selectedTab从欢迎页导航到第一个练习了!

接下来,你将在ExerciseView.swift中施展更大的魔法。

进展到下一个练习

你的用户将耗费大量的体力来进行练习。你可以通过在他们点击完成按钮时进展到下一个练习来减少他们在你的应用程序中的工作量。

➤ 首先,通过在ExerciseView中分离出开始和完成按钮来简化你的生活。在ExerciseView.swift中,用这个HStack替换Button("Start/Done") { }

HStack(spacing: 150) {
  Button("Start Exercise") { }
  Button("Done") { }
}

保持fontpadding修改器在HStack上,所以两个按钮都使用title3字体大小,并且padding围绕HStack

现在你准备好为Done按钮实现你的省时动作了。点击Done进入下一个ExerciseView,而在最后一个ExerciseView中点击ExerciseView则进入WelcomeView

➤ 将此添加到ExerciseView中的其他属性:

var lastExercise: Bool {
  index + 1 == Exercise.exercises.count
}

你创建一个计算属性来检查这是否是最后一个练习。

➤ 在ExerciseView.swift中,用下面的代码替换 Button("Done") { }

Button("Done") {
  selectedTab = lastExercise ? 9 : selectedTab + 1
}

Swift

三元条件运算符测试?之前指定的条件,如果条件为真,则评估?之后的第一个表达式。否则,它将评估:之后的表达式。

在本章后面,当用户在最后一个ExerciseView上点击Done时,你会显示SuccessView。然后解除SuccessView将进入WelcomeView

➤ 刷新钉住的ContentView预览的实时预览,然后点开始来加载第一个练习。在每个练习页上点一下完成,进入下一个练习。在最后一个练习上点完成,回到欢迎页面。

Tap your way through the pages.

下一页导航很好,但你的用户可能想直接跳到他们喜欢的练习。你会很快实现这一点。

与页码和评级互动

本节将学习的技能:传递一个值与传递一个Binding;使Image可触摸。

用户希望HeaderView中的页码能够指示当前的页面。一个方便的指示器是符号的填充版本。在light模式下,它是一个黑色背景上的白色数字。

Light mode 2.circle and 2.circle.fill

➤ 在HeaderView.swift中,用以下代码替换HeaderView的内容:

@Binding var selectedTab: Int  // 1
let titleText: String

var body: some View {
  VStack {
    Text(titleText)
      .font(.largeTitle)
    HStack {  // 2
      ForEach(0 ..< Exercise.exercises.count) { index in  // 3
        let fill = index == selectedTab ? ".fill" : ""
        Image(systemName: "\(index + 1).circle\(fill)")  // 4
      }
    }
    .font(.title2)
  }
}
  1. HeaderView不改变selectedTab的值,但是当其他视图改变这个值时,它需要重新绘制自己。你通过将selectedTab声明为@Binding来创建这种依赖关系。
  2. 欢迎页面并不真正需要一个页面number,所以你从HStack中删除"hand.wave"符号。

  3. 为了适应任何数量的练习,你通过在exercises数组上循环来创建HStack

  4. 你创建每个符号的名字,通过连接代表整数index + 1String,文本.circle.fill或空String,取决于index是否与selectedTab匹配。你使用一个三元组条件表达式来选择".fill"和`""。

➤ 现在previews需要这个新的参数,所以用下面的内容替换Group的内容:

HeaderView(selectedTab: .constant(0), titleText: "Squat")
  .previewLayout(.sizeThatFits)
HeaderView(selectedTab: .constant(1), titleText: "Step Up")
  .preferredColorScheme(.dark)
  .environment(\.sizeCategory, .accessibilityLarge)
  .previewLayout(.sizeThatFits)

接下来,你需要更新WelcomeViewExerciseViewHeaderView的实例。

➤ 在WelcomeView.swift中,将HeaderView(titleText: "Welcome")改为如下:

HeaderView(selectedTab: $selectedTab, titleText: "Welcome")

➤ 在ExerciseView.swift中,将 HeaderView(titleText: Exercise.exercices[index].exerciseName)改为如下:

HeaderView(
  selectedTab: $selectedTab,
  titleText: Exercise.exercises[index].exerciseName) 

➤ 刷新钉子的ContentView预览的实时预览,然后点开始加载第一个练习。1的符号被填满。在每个练习页上点击完成,进入下一个练习页,并看到每页的符号高亮:

ExerciseView with page numbers

使页码可被点选

许多用户希望页码能通过点击进入该页来响应。

➤ 在HeaderView.swift中,给Image(systemName:)添加这个修改器:

.onTapGesture {
  selectedTab = index
}

这个修改器通过设置selectedTab的值对用户点击Image做出反应。

➤ 刷新钉住的ContentView预览的实时预览,然后点击一个页码导航到该练习页:

Tap page number to jump to last exercise.

恭喜你,你已经通过提供用户所期望的所有导航功能,在看不见的地方改善了你的应用程序的用户体验。

显示和改变评级

onTapGesture修改器对于使RatingView的行为符合大家的期望也很有用。点击五个评级符号中的一个会改变该符号和前面所有符号的颜色为红色。其余的符号为灰色。

Rating view: rating = 3

➤ 首先,给ExerciseView添加一个rating属性。在ExerciseView.swift中,将其添加到其他属性中:

@State private var rating = 0

在第8章"保存设置"中,你将把rating值和exerciseName一起保存,所以ExerciseView需要这个rating属性。你使用属性包装器@State是因为rating必须能够改变,而ExerciseView拥有这个属性。

➤ 现在向下滚动到RatingView(),用这一行代替它:

RatingView(rating: $rating)

你把对rating的绑定传递给RatingView,因为实际的值变化将在那里发生。

➤ 在RatingView.swift中,在RatingView_Previews中,用这一行替换RatingView()

RatingView(rating: .constant(3))

➤ 现在用以下代码替换RatingView的内容:

@Binding var rating: Int  // 1
let maximumRating = 5  // 2

let onColor = Color.red  // 3
let offColor = Color.gray

var body: some View {
  HStack {
    ForEach(1 ..< maximumRating + 1) { index in
      Image(systemName: "waveform.path.ecg")
        .foregroundColor(
          index > rating ? offColor : onColor)  // 4
        .onTapGesture {  // 5
          rating = index
        }
    }
  }
  .font(.largeTitle)
}
  1. ExerciseView传递给RatingView一个对其@State属性rating的绑定。
  2. 大多数应用程序使用5级评级系统,但你可以为maximumRating设置一个不同的值。
  3. rating是1和maximumRating之间的整数时,第一个rating符号应该是onColor,其余符号应该是offColor
  4. HStack中,你仍然循环处理这些符号,但现在如果符号的index高于rating,你就把它的foregroundColor设置为offColor
  5. 当用户点击一个符号时,你将rating设置为该index

➤ 刷新钉住的ContentView预览的实时预览,然后点一个页码导航到该练习页。点不同的符号可以看到颜色的变化:

Rating view

➤ 导航到其他练习页并设置它们的评级,然后导航到各页,查看评级仍然是您设置的值。

➤ 单击销钉按钮,取消销钉ContentView的预览。

显示和隐藏模态表

本节将学习的技能:更多关于@State@Binding的练习;使用Bool标志来显示模态表单;通过切换Bool标志或使用@Environment(\.presentationMode)来关闭模态表单。

HistoryViewSuccessView是在WelcomeViewExerciseView上滑动的模式表。你可以通过点击它的circled-xContinue按钮,或者向下拖动它来解除模态表。

显示HistoryView

显示或隐藏模式表的一种方法是用一个Bool标志。

➤ 在WelcomeView.swift中,向WelcomeView添加这个State属性:

@State private var showHistory = false

当这个视图加载时,它不显示HistoryView

➤ 将Button("History") { }替换为以下内容:

Button("History") {
  showHistory.toggle()
}
.sheet(isPresented: $showHistory) {
  HistoryView(showHistory: $showHistory)
}

点击History按钮可以将showHistory的值从false切换到true。这导致sheet修改器呈现HistoryView

你把一个绑定的$showHistory传递给HistoryView,这样它就可以在用户退出HistoryView时把这个值改回false

➤ 你很快就会编辑HistoryView来做这个。但首先,在ExerciseView.swift中重复上面的步骤。

隐藏HistoryView

实际上,有两种方法可以dismiss一个模态表。这种方式是最容易理解的。你设置一个标志为true来显示工作表,所以你设置标志为false来隐藏它。

➤ 在HistoryView.swift中,添加这个属性:

@Binding var showHistory: Bool

这与你从WelcomeView传递给HistoryView的参数相匹配。

➤ 在previews中添加这个新参数:

HistoryView(showHistory: .constant(true))

➤ 现在,用以下内容替换Button(action: {}) {

Button(action: { showHistory.toggle() }) {

你把showHistory切换回false,这样HistoryView就消失了。

➤ 回到WelcomeView.swift,开始实时预览,然后点击History

Testing WelcomeView History button

HistoryViewWelcomeView上滑动,这是它应该做的。点一下dismiss按钮可以隐藏它。你也可以在HistoryView上向下拖动。

➤ 也可以检查ExerciseView.swift中的History按钮:

Testing ExerciseView History button

你的应用程序有另一个模态表来显示和隐藏。你将以与HistoryView相同的方式显示它,但你将使用不同的方式来隐藏它。

显示SuccessView

ExerciseView.swift中,你将修改Done按钮的动作,这样当用户在最后一个练习上点击它时,就会显示SuccessView

➤ 首先,添加@State属性:

@State private var showSuccess = false

➤ 然后用一个if-else语句替换Done按钮动作,并添加sheet(isPresented:)修改器:

Button("Done") {
  if lastExercise {
    showSuccess.toggle()
  } else {
    selectedTab += 1
  }
}
.sheet(isPresented: $showSuccess) {
  SuccessView()
}

注意你没有把$showSuccess传给SuccessView()。你要用一种不同的方式来解散SuccessView。第一个区别是,它没有使用Bool标志。

隐藏SuccessView

这种方式的内部工作原理很复杂,但它简化了你的代码,因为你不需要向模态表传递参数。而且你可以在每个模态视图中使用完全相同的两行代码。

➤ 在SuccessView.swift中,给SuccessView添加这个属性:

@Environment(\.presentationMode) var presentationMode

@Environment(\.presentationMode)允许读取由关键路径.presentationMode引用的环境变量。

每个视图的环境都有诸如colorSchemelocale和设备的accessibility设置等属性。其中许多都是从应用程序中继承的,但视图的presentationMode是特定于视图的。它是对一个结构的绑定,有一个isPresented属性和一个dismiss()方法。

当你查看SuccessView时,它的isPresented值是true。当用户点击Continue按钮时,你想把这个值改为false

但是@Environment属性包装器不允许你直接设置一个环境值。你不能写presentationMode.isPresented = false

以下是你需要做的事情。

➤ 在SuccessView.swift中,将Button("Continue") { }替换为以下内容:

Button("Continue") {
  presentationMode.wrappedValue.dismiss()
}

你访问底层的PresentationMode实例,作为presentationMode绑定的wrappedValue,然后调用PresentationMode方法dismiss()。这个方法并不是一个切换。如果当前呈现的是视图,它就会解散该视图。如果视图当前没有呈现,它就不做任何事情。

➤ 回到ExerciseView.swift,将previews中的一行改为如下:

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

为了测试显示和隐藏SuccessView,你将预览最后一个练习页。

➤ 刷新预览并开始实时预览。你应该看到阳光礼赞。点选Done

Tap Done on the last exercise to show SuccessView.

➤ 点Continue,关闭SuccessView

还有一件事

High Five! SuccessView的信息给你的用户一种成就感。当他们点击Continue时,再次看到最后的ExerciseView,感觉不对。再次看到欢迎页面不是更好吗?

➤ 在SuccessView.swift中,添加这个属性:

@Binding var selectedTab: Int

SuccessView需要能够改变这个值。

➤ 也要在previews中添加它:

SuccessView(selectedTab: .constant(3))

➤ 并将这一行添加到continue按钮动作中:

selectedTab = 9

WelcomeView有标签值9。

Note

你可以在dismiss调用的上方或下方添加它,但在上方添加它感觉更像是正确的事情顺序。

现在回到ExerciseView.swift,把这个参数传给SuccessView

➤ 把SuccessView()改成这一行。

SuccessView(selectedTab: $selectedTab)

➤ 最后,回到ContentView.swift,看看它的工作情况。运行实时预览,点第4页按钮,点Done,然后点Continue

Dismissing SuccessView returns to WelcomeView.

Note

如果你没有看到欢迎页面,按Command-B键重建应用程序,然后再试。

SuccessView上点击Continue可以显示WelcomeView,并关闭SuccessView

你使用了一个Bool标志来显示模态表单。你使用了Bool标志和环境变量.\presentationMode来关闭工作表。

在本章中,你已经使用视图值来导航你的应用程序的视图并显示模态表单。在下一章,你将观察对象。你将订阅一个Timer publisher,并将HistoryStore重做为一个ObservableObject

关键点

  • 声明式应用开发意味着你既要声明你希望UI中的视图是什么样子,也要声明它们依赖什么数据。SwiftUI框架负责在视图应该出现时创建它们,并在它们所依赖的数据发生变化时更新它们。
  • Data access = dependency:在你的视图中读取一个数据会在该视图中创建一个对该数据的依赖性。
  • 单一真理源:每一个数据都有一个真理源,内部或外部。无论真相的来源是什么,你都应该有一个单一的真相来源。
  • 属性包装器增强了属性的行为。@State@Binding@EnvironmentObject声明视图对属性所代表的数据的依赖性。
  • @Binding声明了对另一个视图所拥有的@State属性的依赖性。@EnvironmentObject声明对一些共享数据的依赖性,比如符合ObservableObject的引用类型。
  • 使用Bool@State属性来显示和隐藏模版页或子视图。使用@Environment(\.presentationMode)作为另一种方式来解散模态表。