接下来的两个项目我们将探索具有多个屏幕,加载和保存用户数据以及具有更复杂的用户界面的应用程序,这些都是在基础知识之上拓展的SwiftUI技能。
本项目将构建 iExpense 应用程序,这是一个费用跟踪器,可以将个人成本与业务成本区分开。它的核心是一个表单(您花了多少钱?)和一个列表(这里是您花了多少钱),但是要完成这两件事,您需要学习:
以及更多的内容。
有很多事情要做,所以让我们开始吧:使用 Single View App 模板创建一个新的 iOS 应用程序,将其命名为“ iExpense ”。我们将把它用于主要项目,但首先让我们仔细看一下该项目所需的新技术…
SwiftUI的State
属性包装器旨在用于当前视图的本地简单数据,一旦要在视图之间共享数据,就不管用了。
让我们用一些代码来分解它-这是一个存储用户的名字和姓氏的结构:
struct User {
var firstName = "Bilbo"
var lastName = "Baggins"
}
可以在 SwiftUI 视图中创建@State
属性并通过$user.firstName
和$user.lastName
来使用,如下所示:
struct ContentView: View {
@State private var user = User()
var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName).")
TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}
一切正常:SwiftUI 足够聪明,可以理解一个对象包含我们所有的数据,并且在任一值更改时都会更新UI。在幕后,实际上发生的是,每次结构体内部的值更改时,整个结构都会更改-就像我们每次键入名字或姓氏的键一样,都是新用户。听起来可能很浪费,但实际上速度非常快。
前面研究类和结构体之间的差异时曾提到了两个重要的差异。首先,结构体始终具有唯一的所有者,而对于类,多个事物可以指向相同的值。其次,类在更改其属性的方法之前不需要关键字mutating
,因为可以更改常量类的属性。
实际上,这意味着如果有两个 SwiftUI 视图,并且将它们都发送给相同的结构体,则它们实际上都具有该结构体的唯一副本;如果一个更改,另一个将看不到该更改。另一方面,如果创建一个类的实例并将其发送到两个视图,则它们将共享更改。
对于 SwiftUI 开发人员而言,这意味着如果我们想在多个视图之间共享数据——在两个或多个视图指向同一数据,以便一个或多个视图都得到更改——我们需要使用类而不是结构。
因此,请将User
结构更改为类。由此:
struct User {
对此:
class User {
现在再次运行该程序,看看您的想法。
剧透:它不再起作用了。当然,我们可以像以前一样在文本字段中键入内容,但是上面的文本视图不会改变。
当使用时@State
,我们要求 SwiftUI 监视属性的更改。因此,如果我们更改字符串,翻转布尔值,添加到数组等,则属性已更改,SwiftUI将重新调用body
视图的属性。
当User
是一个结构体时,每次我们修改该结构体的属性时,Swift 实际上都会创建该结构体的新实例。@State
能够发现该更改,并自动重新加载我们的视图。现在我们有了一个类,该行为不再发生:Swift 可以直接修改该值。
还记得我们如何在mutating
修改属性的 struct 方法中使用关键字吗?这是因为,如果我们将结构的属性创建为变量,但结构本身是常量,则我们无法更改属性—— Swift 需要能够在属性更改时销毁并重新创建整个结构,而这对于恒定的结构。类并不需要的mutating
关键词,因为即使类实例被标记为常数 swift 仍然可以修改变量属性。
我知道所有这些听起来都是理论上的,但是这里有一个转折:现在User
是一个类,属性本身并没有发生变化,因此@State
不会注意到任何东西并且无法重新加载视图。是的,类内的值在变化,但@State
不监控这些,所以有效地发生的事情是,正在改变我们的类里面的值,但认为没有被重新加载,以反映这种变化。
为了解决这个问题,现在该丢下@State
了。相反,我们需要一个功能更强大的属性包装器@ObservedObject
–现在让我们看一下……
如果想使用一个类与 SwiftUI 数据且该数据跨多个视图共享,SwiftUI 给了两个属性有用的包装器:@ObservedObject
和@EnvironmentObject
。稍后将研究环境对象,但现在让我们集中关注被观察对象。
这是一些创建User
类的代码,并在视图中显示用户数据:
class User {
var firstName = "Bilbo"
var lastName = "Baggins"
}
struct ContentView: View {
@State private var user = User()
var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName).")
TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}
但是,该代码无法按预期工作:我们已使用标记了user
属性@State
,该属性旨在跟踪本地结构而不是外部类。结果,我们可以在文本字段中键入内容,但是上面的文本视图不会被更新。
为了解决这个问题,需要将类发生的变化告诉 SwiftUI,这种变化应该导致让 SwiftUI 重新加载正在观看这个类的所有视图。
User
类有两个属性:firstName
和lastName
。每当这两个更改中的任何一个更改时,我们都希望通知正在观看这个类的所有视图发生了更改,以便可以重新加载它们。可以使用@Published
属性观察器执行此操作,如下所示:
class User {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}
@Published
算是半个@State
:它告诉 Swift,只要这两个属性中的任何一个发生更改,它都应该向任何正在观看它们的 SwiftUI 视图发送一条通知,好让视图重新加载。
这些视图如何知道哪些类可以发出这些通知?那是另一个属性包装器,@ObservedObject
算是另外半个@State
,它告诉SwiftUI 监视类中是否有任何更改声明。
因此,将user
属性更改为:
@ObservedObject var user = User()
这里删除了访问控制private
,但是是否使用取决于具体情况。如果打算与其他视图共享该对象,则将其标记为private
只会引起混乱。
虽然使用了@ObservedObject
,但是编译报错。这不是问题,事实上,它是意料之中的并且也很容易修复:
@ObservedObject
属性包装器只能用于符合ObservableObject
协议的类型。该协议没有任何要求,意味着“我们希望其他事物能够监视此更改”。
因此,将User
类修改为:
class User: ObservableObject {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}
代码现在编译通过,而且现在实际上起作用了。运行应用程序并看到文本视图更新时,无论是文本字段被改变。live preview 也可以看见结果,代码参见项目中的 ObservedObjectView.swift 。
综上,现在不仅仅可以使用@State
,而且可以通过三个步骤声明局部状态:
ObservableObject
协议的类。@Published
标记一些属性,以便使用该类的所有视图在更改时都得到更新。@ObservedObject
属性包装器创建类的实例。最终结果是可以将状态存储在一个外部对象中,甚至更好的是,现在可以在多个视图中使用该对象并将它们都指向相同的值。
在SwiftUI中,有几种显示视图的方法,其中最基本的一种是sheet:在现有视图的顶部呈现一个新视图。在iOS上,这会自动提供类似于卡片的演示,其中,当前视图会向远处滑动一点,新视图会在顶部显示动画。
Sheet 的工作方式很像警报,因为我们不会直接使用诸如mySheet.present()
或类似的代码来显示 sheet。取而代之的是,我们定义了要显示 sheet 的条件,当这些条件变为真或假时,将分别显示或隐藏 sheet。
让我们从一个简单的示例(代码参见项目中的 ShowAndHideView.swift)开始,它将显示一个视图并使用 sheet 显示另一个视图。首先,我们创建要显示在 sheet 中的视图,如下所示:
struct SecondView: View {
var body: some View {
Text("第二视图")
}
}
该视图没有什么特别的,它不知道将在 sheet 中显示,也不需要知道将在 sheet 中显示。
接下来,简单创建初始视图,该视图将显示第二个视图,然后添加代码:
struct ContentView: View {
var body: some View {
Button("显示第二视图") {
// 显示第二视图
}
}
}
共需要四个步骤。
首先,需要某种状态来跟踪工作表是否正在显示。就像警报一样,这可以是一个简单的布尔值,因此请添加以下属性:
@State private var showingSheet = false
其次,需要在点击按钮时进行切换,因此将// 显示第二视图
注释替换为:
self.showingSheet.toggle()
第三,需要将工作表附加到视图层次结构的某处。记得吗,前面曾经使用alert(isPresented:)
与状态属性的双向绑定来显示警报,这里使用几乎相同的内容:sheet(isPresented:)
。
sheet()
是和alert()
一样的修饰器,因此将此修饰器添加到按钮中:
.sheet(isPresented: $showingSheet) {
// sheet 的内容
}
第四,需要确定 sheet 中应实际包含的内容。这里就是创建并显示SecondView
的实例。
完整代码如下所示:
struct ShowAndHideView: View {
@State private var showingSheet = false
var body: some View {
Button("显示第二视图") {
self.showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
SecondView()
}
}
}
如果现在运行该程序,将看到可以单击该按钮使第二个视图从底部向上滑动,然后将其向下拖动可以将其关闭。
创建这样的视图时,可以传入需要工作的任何参数。例如,我们可以要求SecondView
发送一个可以显示的名称,如下所示:
struct SecondView: View {
var name: String
var body: some View {
Text("Hello, \(name)!")
}
}
现在仅SecondView()
在工作表中使用还不够用–我们需要传递一个名称字符串以显示出来。例如,我们可以这样输入我的Twitter用户名:
.sheet(isPresented: $showingSheet) {
SecondView(name: "@两只老虎")
}
现在工作表将显示“ Hello,@两只老虎”。
在继续之前,还要演示另一件事,即如何使视图自行关闭。是的,您已经看到用户可以向下滑动,但是有时您会希望以编程方式关闭视图–例如,由于按下了按钮,使视图消失了。
SwiftUI提供了两种方法来执行此操作,但最简单的方法是使用另一个属性包装器(是的,我意识到,解决SwiftUI问题的方法常常是使用另一个属性包装器)。
无论如何,这个称为@Environment
,它使我们能够创建存储外部提供给我们的值的属性。用户处于亮模式还是暗模式?他们是否要求较小或较大的字体?他们在哪个时区?所有这些以及更多都是来自环境的值,在这种情况下,我们将从环境中读取视图的表示方式。
视图的呈现模式仅包含两个数据,但两者都很有用:一个用于存储视图当前是否显示在屏幕上的属性,以及一种让我们立即关闭视图的方法。
要进行尝试,向SecondView
中添加一个presentationMode
的属性,该属性附加到存储在应用程序环境中的演示模式变量中:
@Environment(\.presentationMode) var presentationMode
现在,SecondView
中将文本视图和下面的按钮封装到一个 VStack 中:
Button("关闭 sheet") {
self.presentationMode.wrappedValue.dismiss()
}
wrappedValue
在那里是必需的,因为presentationMode
实际上是个绑定,因此它可以由系统自动更新,而我们只需要挖掘其中,检索实际表现模式来关闭该视图。
无论如何,有了该按钮,您现在应该可以通过按按钮显示和隐藏工作表。
SwiftUI 提供了onDelete()
修饰器,用来控制如何从集合中删除对象。在实践中几乎总是和List
与ForEach
一起使用:我们创建一个使用ForEach
表示的行列表,然后附加onDelete()
到该ForEach
行,以便用户可以删除不需要的行。
首先,让我们构造一个可以使用的示例:一个显示数字的列表,每当我们点击按钮时,都会出现一个新数字。这是该代码:
struct ContentView: View {
@State private var numbers = [Int]()
@State private var currentNumber = 1
var body: some View {
VStack {
List {
ForEach(numbers, id: \.self) {
Text("\($0)")
}
}
Button("添加一行数字") {
self.numbers.append(self.currentNumber)
self.currentNumber += 1
}
}
}
}
现在,您可能会认为不需要ForEach
,该列表由完全动态的行组成,因此我们可以这样编写:
List(numbers, id: \.self) {
Text("\($0)")
}
那也可以,但是这是我们的第一个怪癖:onDelete()
修饰符用于ForEach
,因此,如果希望用户能从列表中删除项目,则必须将项目放在ForEach
内。只有动态行时,使用ForEach
确实意味着少量的额外代码,但是从另一方面来说,这意味着创建仅可以删除某些行的列表会更容易。
为了使onDelete()
起作用,我们需要实现一个方法,该方法将接收单个IndexSet
类型参数。这有点像一组整数,只不过它是经过排序的,它只是告诉我们ForEach
应该删除的所有项目的位置。
因为ForEach
是完全由单个数组创建的,所以可以直接将索引集直接传递给numbers
数组,它具有remove(atOffsets:)
接受索引集的特殊方法。
有关
remove(atOffsets:)
在文档中的内容
- 这个方法实际上是 RangeReplaceableCollection 协议中的方法,这种协议是支持用另一个集合的元素替换任意一个子集合的集合。
- 这个方法没有具体说明,只是说对符合 MutableCollection 协议的有效
- MutableCollection 是一种支持下标赋值( subscript assignment )的集合。其实现类型包括 Array
- RangeReplaceableCollection 协议中有关 remove 的方法还有很多,具体请见文档。
func removeRows(at offsets: IndexSet) { //这种写法没大懂
numbers.remove(atOffsets: offsets)
}
最后,告诉 SwiftUI 在要删除数据时调用该方法:
ForEach(numbers, id: \.self) {
Text("\($0)")
}
.onDelete(perform: removeRows) // 参数呢?
现在运行应用程序,然后添加一些数字。在列表中的任何行上从右向左滑动,会发现出现一个删除按钮。可以点击它,也可以通过进一步滑动来删除这一行。
SwiftUI 还有另外一个技巧:可以在导航栏中添加“编辑/完成”(Edit/Done)按钮,让用户可以更轻松地删除几行。
首先,将VStack
包裹在NavigationView
中,然后将此修饰符添加到VStack
中:
.navigationBarItems(trailing: EditButton())
可以说,网站和应用程序之间的最大区别在于它们对用户数据的处理方式。一方面,网站会通过跟踪Cookie,投放再营销广告并观看我们的举动来尽最大努力侵犯隐私,因此很少有用户希望通过更多数据来信任他们。另一方面,我们非常希望应用程序能够存储我们的数据——我们希望它们能,而如果每个使用GDPR的应用程序在启动时都提示“我们可以为您提供Cookie吗?”则会很怪异。
GDPR
《通用数据保护条例》(General Data Protection Regulation,简称GDPR)为欧洲联盟的条例,前身是欧盟在1995年制定的《计算机数据保护法》。
2018年5月25日,欧洲联盟出台《通用数据保护条例》
因此,iOS为我们提供了几种读取和写入用户数据的方式也就不足为奇了,我想在这里看看其中两种。
第一个称为UserDefaults
,它使我们可以存储直接附加到应用程序的少量用户数据。“ 少量”没有具体的数字,不过,存储在UserDefaults
中的所有内容都会在应用启动时自动加载——如果内容很多,则应用启动速度会变慢。建议不超过512KB。
UserDefaults
非常适合存储用户设置和其他重要数据:您可能会跟踪用户上次启动该应用程序的时间,他们上次阅读的新闻报道或其他被动收集的信息。
但是,有一个陷阱:它是类似字符串类型。这有点像个玩笑的名字,因为像 Swift 这样的类型安全语言使用的是“强类型”,其中每个常量和变量都具有特定类型,例如Int
或String
,而“类似字符串类型”表示某些代码在它们所处的地方使用字符串可能会引起问题。
这是一个带有按钮的视图,该视图显示点击计数,并在每次点击该按钮时递增计数:
struct ContentView: View {
@State private var tapCount = 0
var body: some View {
Button("Tap count: \(tapCount)") {
self.tapCount += 1
}
}
}
我们希望保存用户做出的点击次数,以使他们将来再次使用该应用程序时,可以从上次停止的地方继续。
做到这一点只需要做两个改变。首先,需要将点击计数发生变化时写入UserDefaults
,在self.tapCount += 1
行下面添加:
UserDefaults.standard.set(self.tapCount, forKey: "Tap")
在这一行代码中,可以看到三件事:
UserDefaults.standard
。这是UserDefaults
附加到我们应用程序的内置实例,但是在更高级的应用程序中,您可以创建自己的实例。例如,如果要在多个应用程序扩展中共享默认设置,则可以创建自己的UserDefaults
实例。set()
方法可以接受任何类型的数据——整数,布尔值,字符串等。UserDefaults
中读取数据。说回读数据,而不是从tapCount
设置为0 开始,相反,我们应该UserDefaults
像这样使它从回读值:
@State private var tapCount = UserDefaults.standard.integer(forKey: "Tap")
请注意,它如何使用完全相同的键名,以确保它读取相同的整数值。
大叔实验记录
未开始 live preview 预览时,界面数字显示为 0,开始 live preview 后,就显示为保存在 UserDefaults 中的数字了。即使停了 live preview (又显示为 0)之后再开始,仍然看到的是存储的数字。
有两件事情在代码中看不到,但是很重要。首先,如果没有设置“ Tap”键,应用程序如果找不到key,将仅发送回0。
有时使用默认值(例如0)会有所帮助,但有时可能会造成混淆。例如,使用布尔值时,如果boolean(forKey:)
找不到所需的键,则返回false ,但是,false是您自己设置的值,还是意味着根本没有值?
其次,iOS 需要花费一些时间将数据写入永久存储,具体多少时间我们不知道,但几秒钟应该这样够了。
UserDefaults
对于存储整数和布尔值之类的简单设置非常有用,但是对于复杂数据(例如自定义Swift类型),我们需要做更多的工作。如下面这个简单的结构体:
struct User {
var firstName: String
var lastName: String
}
它有两个字符串,但并不特殊。当使用这样的数据时,Swift 提供了一个很棒的协议,称为Codable
:一种专门用于归档和取消归档数据的协议,这是一种“将对象转换为纯文本然后再次转换”的奇特方法。
我们将在未来的项目中对Codable
进行更多的研究,但是目前我们的需求很简单:我们想要归档一个自定义类型,以便我们可以将其放入其中UserDefaults
,然后在从UserDefaults
中返回时对其进行解档。
对于仅具有简单属性的类型(字符串,整数,布尔值,字符串数组等),支持归档和取消归档的唯一要求是符合Codable
协议,如下所示:
struct User: Codable {
var firstName: String
var lastName: String
}
Swift 将自动为我们生成一些代码,这些代码将User
根据需要对实例进行存档和解档,但是我们仍然需要告诉 Swift 何时存档以及如何处理数据。
该过程的这一部分由称为的新类型提供支持JSONEncoder
。它的工作是获取符合Codable
条件的东西,然后以 JavaScript Object Notation(JSON)的形式发送回该对象——虽然该名称暗示该对象特定于JavaScript,但实际上,我们都使用它,因为它是非常快速和简单。
Codable
协议并非要求我们一定使用JSON,实际上其他格式也可以使用,但 JSON 是迄今为止最常见的格式。本例中,我们实际上并不在乎使用哪种数据,因为它们只会存储在UserDefaults
中。
要将user
数据转换为JSON数据,我们需要在JSONEncoder
上调用encode()
方法。这可能会引发错误,因此应使用try
或try?
巧妙地调用它。例如,如果我们有一个属性来存储User
实例,如下所示:
@State private var user = User(firstName: "Taylor", lastName: "Swift")
然后,我们可以创建一个按钮,将用户存档并保存为UserDefaults
:
Button("Save User") {
let encoder = JSONEncoder()
if let data = try? encoder.encode(self.user) {
UserDefaults.standard.set(data, forKey: "UserData")
}
}
当我们返回另一种方式时(当我们拥有JSON数据并且想要将其转换为Swift Codable
类型时),我们应该使用JSONDecoder
而不是JSONEncoder()
,但是过程大致相同。