第6章:为你的应用程序添加功能¶
在上一章中,你对你的应用程序的数据进行了结构化处理,使其更有效率,更不容易出错。在这一章中,你将实现你的用户在浏览和使用你的应用程序时期望的大部分功能。现在,你需要管理你的应用程序的数据,使数值顺利地流经你的应用程序的视图和子视图。
管理你的应用程序的数据¶
SwiftUI
有两个指导原则来管理数据如何流经你的应用程序。
Data access = dependency
:在你的视图中读取一块数据会在该视图中创建一个对该数据的依赖性。每个视图都是其数据依赖的一个函数--它的输入或状态。- 单一真理源:视图读取的每一块数据都有一个真理源,这个真理源要么为视图所有,要么为视图外部。无论真理源在哪里,你都应该有一个单一的真理源。
数据流的工具¶
SwiftUI
提供了几个工具来帮助你管理应用程序中的数据流。SwiftUI
框架负责在视图应该出现时创建它们,并在它们所依赖的数据发生变化时更新它们。
属性包装器增强了属性的行为。SwiftUI
特定的包装器,如@State
、@Binding
和@EnvironmentObject
声明视图对属性所代表的数据的依赖性。
每个包装器表示不同的数据来源:
- 一个
@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
抱怨说你传递了一个额外的参数,因为你还没有给WelcomeView
或ExerciseView
添加selectedTab
属性。你很快就会这样做的。
- 你把绑定的
$selectedTab
传递给WelcomeView
和ExerciseView
,这样TabView
就可以在他们改变其值时做出反应。 - 你用
9
作为WelcomeView
的标签。 - 用
Exercise.exercise
中的索引来标记每个ExerciseView
。
➤ 在你去编辑WelcomeView.swift
和ExerciseView.swift
之前,点击pin
按钮,把ContentView
的预览钉在上面:
当你改变WelcomeView.swift
和ExerciseView.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
中恢复预览:
进展到第一个练习¶
接下来,你将实现欢迎页面的开始按钮动作,以显示第一个ExerciseView
。
➤ 在WelcomeView.swift
中,用这个替换Button(action: { }) {
:
Button(action: { selectedTab = 0 }) {
➤ 现在为钉子的ContentView
预览打开实时预览,然后点击开始。
Note
你不能在WelcomeView
预览中预览这个动作,因为它不包括ExerciseView
。点击开始不会有任何进展。
你已经用selectedTab
从欢迎页导航到第一个练习了!
接下来,你将在ExerciseView.swift
中施展更大的魔法。
进展到下一个练习¶
你的用户将耗费大量的体力来进行练习。你可以通过在他们点击完成按钮时进展到下一个练习来减少他们在你的应用程序中的工作量。
➤ 首先,通过在ExerciseView
中分离出开始和完成按钮来简化你的生活。在ExerciseView.swift
中,用这个HStack
替换Button("Start/Done") { }
:
HStack(spacing: 150) {
Button("Start Exercise") { }
Button("Done") { }
}
保持font
和padding
修改器在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
预览的实时预览,然后点开始来加载第一个练习。在每个练习页上点一下完成,进入下一个练习。在最后一个练习上点完成,回到欢迎页面。
下一页导航很好,但你的用户可能想直接跳到他们喜欢的练习。你会很快实现这一点。
与页码和评级互动¶
本节将学习的技能:传递一个值与传递一个Binding
;使Image
可触摸。
用户希望HeaderView
中的页码能够指示当前的页面。一个方便的指示器是符号的填充版本。在light
模式下,它是一个黑色背景上的白色数字。
➤ 在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)
}
}
HeaderView
不改变selectedTab
的值,但是当其他视图改变这个值时,它需要重新绘制自己。你通过将selectedTab
声明为@Binding
来创建这种依赖关系。-
欢迎页面并不真正需要一个页面
number
,所以你从HStack
中删除"hand.wave"
符号。 -
为了适应任何数量的练习,你通过在
exercises
数组上循环来创建HStack
。 - 你创建每个符号的名字,通过连接代表整数
index + 1
的String
,文本.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)
接下来,你需要更新WelcomeView
和ExerciseView
中HeaderView
的实例。
➤ 在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
的符号被填满。在每个练习页上点击完成,进入下一个练习页,并看到每页的符号高亮:
使页码可被点选¶
许多用户希望页码能通过点击进入该页来响应。
➤ 在HeaderView.swift
中,给Image(systemName:)
添加这个修改器:
.onTapGesture {
selectedTab = index
}
这个修改器通过设置selectedTab
的值对用户点击Image
做出反应。
➤ 刷新钉住的ContentView
预览的实时预览,然后点击一个页码导航到该练习页:
恭喜你,你已经通过提供用户所期望的所有导航功能,在看不见的地方改善了你的应用程序的用户体验。
显示和改变评级¶
onTapGesture
修改器对于使RatingView
的行为符合大家的期望也很有用。点击五个评级符号中的一个会改变该符号和前面所有符号的颜色为红色。其余的符号为灰色。
➤ 首先,给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)
}
ExerciseView
传递给RatingView
一个对其@State
属性rating
的绑定。- 大多数应用程序使用5级评级系统,但你可以为
maximumRating
设置一个不同的值。 - 当
rating
是1和maximumRating
之间的整数时,第一个rating
符号应该是onColor
,其余符号应该是offColor
。 - 在
HStack
中,你仍然循环处理这些符号,但现在如果符号的index
高于rating
,你就把它的foregroundColor
设置为offColor
。 - 当用户点击一个符号时,你将
rating
设置为该index
。
➤ 刷新钉住的ContentView
预览的实时预览,然后点一个页码导航到该练习页。点不同的符号可以看到颜色的变化:
➤ 导航到其他练习页并设置它们的评级,然后导航到各页,查看评级仍然是您设置的值。
➤ 单击销钉按钮,取消销钉ContentView
的预览。
显示和隐藏模态表¶
本节将学习的技能:更多关于@State
和@Binding
的练习;使用Bool
标志来显示模态表单;通过切换Bool
标志或使用@Environment(\.presentationMode)
来关闭模态表单。
HistoryView
和SuccessView
是在WelcomeView
或ExerciseView
上滑动的模式表。你可以通过点击它的circled-x
或Continue
按钮,或者向下拖动它来解除模态表。
显示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
:
HistoryView
在WelcomeView
上滑动,这是它应该做的。点一下dismiss
按钮可以隐藏它。你也可以在HistoryView
上向下拖动。
➤ 也可以检查ExerciseView.swift
中的History
按钮:
你的应用程序有另一个模态表来显示和隐藏。你将以与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
引用的环境变量。
每个视图的环境都有诸如colorScheme
、locale
和设备的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
:
➤ 点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
:
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)
作为另一种方式来解散模态表。