没有人应该开始进行大型项目。你从一个小的琐碎项目开始,并且永远不要期望它会变大。如果这样做,你只会进行过度设计,并且通常认为它比该阶段可能要重要的多。或更糟糕的是,你所设想的庞大工作量可能会吓到你。

该项目继续更新

与@ObservedObject共享SwiftUI状态

@State 只能监控 结构 的更改,很难监控到 类 的更改,这个时候需要使用@ObservedObject

如果你想使用一个类与SwiftUI数据-你会想要做的,如果该数据跨多个视图共享-然后SwiftUI给了我们两个属性包装是有用的:@ObservedObject和@EnvironmentObject。稍后我们将研究环境对象,但现在让我们集中关注观察到的对象。

这是一些创建User类的代码,并在视图中显示用户数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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属性观察器执行此操作,如下所示:

1
2
3
4
class User {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}

@Published是或多或少的一半@State:它告诉Swift,只要这两个属性中的任何一个发生更改,它都应该向任何正在观看它们应该重新加载的SwiftUI视图发送一条通知。

这些视图如何知道哪些类可以发出这些通知?那是另一个属性包装器,@ObservedObject是它的另一半@State–它告诉SwiftUI监视类中是否有任何更改声明。

因此,将user属性更改为此:

1
@ObservedObject var user = User()

我删除了private那里的访问控制,但是是否使用它取决于你的使用情况–如果你打算与其他视图共享该对象,则将其标记为private只会引起混乱。

现在我们正在使用@ObservedObject,我们的代码将不再编译。这不是问题,事实上,它是预期的并且很容易修复:@ObservedObject属性包装器只能用于符合ObservableObject协议的类型。该协议没有任何要求,实际上意味着“我们希望其他事物能够监视此更改”。

因此,将User类修改为此:

1
2
3
4
class User: ObservableObject {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}

我们的代码现在再编译,甚至更好,它现在实际上工作再次-你可以运行应用程序并看到文本视图更新时,无论是文本字段被改变。

显示和隐藏视图

如你所见,@State我们现在不仅仅使用声明局部状态,还执行三个步骤:

创建一个符合ObservableObject协议的类。
用标记一些属性,@Published以便使用该类的所有视图在更改时都得到更新。
使用@ObservedObject属性包装器创建我们的类的实例。
最终结果是我们可以将状态存储在一个外部对象中,甚至更好的是,我们现在可以在多个视图中使用该对象并将它们都指向相同的值。

在SwiftUI中,有几种显示视图的方法,其中最基本的一种是工作表:在我们现有视图的顶部呈现一个新视图。在iOS上,这会自动为我们提供类似于卡片的演示,其中,当前视图会向远处滑动一点,新视图会在顶部显示动画。

工作表的工作方式很像警报,因为我们不会直接使用诸如mySheet.present()或类似的代码来显示工作表。取而代之的是,我们定义了要显示一张纸的条件,当这些条件变为真或假时,将分别显示或取消该纸。

让我们从一个简单的示例开始,它将使用工作表显示一个视图与另一个视图。首先,我们创建要显示在工作表中的视图,如下所示:

1
2
3
4
5
struct SecondView: View {
var body: some View {
Text("Second View")
}
}

该视图没有什么特别的-它不知道将在工作表中显示,也不需要知道将在工作表中显示。

接下来,我们创建初始视图,该视图将显示第二个视图。我们将使其简单,然后添加到其中:

1
2
3
4
5
6
7
struct ContentView: View { 
var body: some View {
Button("Show Sheet") {
// show the sheet
}
}
}

填写此步骤需要四个步骤,我们将逐一解决。

首先,我们需要某种状态来跟踪工作表是否正在显示。就像警报一样,这可以是一个简单的布尔值,因此请添加以下属性ContentView:

@State private var showingSheet = false
其次,我们需要在点击按钮时进行切换,因此将// show the sheet注释替换为:

self.showingSheet.toggle()
第三,我们需要将工作表附加到视图层次结构的某处。如果你还记得的话,我们会使用alert(isPresented:)与状态属性的双向绑定来显示警报,并且在此处使用几乎相同的内容:sheet(isPresented:)。

sheet()是和一样的修饰符alert(),因此请立即将此修饰符添加到我们的按钮中:

1
2
3
.sheet(isPresented: $showingSheet) {
// contents of the sheet
}

第四,我们需要确定表单中应实际包含的内容。在我们的例子中,我们已经完全知道我们想要什么:我们想要创建并显示的实例SecondView。在代码中,这意味着编写SecondView(),然后……嗯……就是这样。

因此,完成的ContentView结构应如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
struct ContentView: View {
@State private var showingSheet = false

var body: some View {
Button("Show Sheet") {
self.showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
SecondView()
}
}
}

如果现在运行该程序,你将看到可以单击该按钮使第二个视图从底部向上滑动,然后将其向下拖动以将其关闭。

创建这样的视图时,可以传入需要工作的任何参数。例如,我们可以要求SecondView发送一个可以显示的名称,如下所示:

1
2
3
4
5
6
7
struct SecondView: View {
var name: String

var body: some View {
Text("Hello, \(name)!")
}
}

现在仅SecondView()在工作表中使用还不够用–我们需要传递一个名称字符串以显示出来。例如,我们可以这样输入我的Twitter用户名:

1
2
3
.sheet(isPresented: $showingSheet) {
SecondView(name: "@twostraws")
}

现在工作表将显示“ Hello,@twostraws”。

斯威夫特是做代表我们这里大量的工作:只要我们说,SecondView有一个名字属性,斯威夫特确保我们的代码甚至不建,直到所有的情况下SecondView()成了SecondView(name: “some name”),从而消除了可能出现的错误的整个范围。

在继续之前,我还要演示另一件事,即如何使视图自行关闭。是的,你已经看到用户可以向下滑动,但是有时你会希望以编程方式关闭视图–例如,由于按下了按钮,使视图消失了。

SwiftUI提供了两种方法来执行此操作,但最简单的方法是使用另一个属性包装器–是的,我意识到,解决SwiftUI问题的方法常常是使用另一个属性包装器。

无论如何,这个称为@Environment,它使我们能够创建存储外部提供给我们的值的属性。用户处于亮模式还是暗模式?他们是否要求较小或较大的字体?他们在哪个时区?所有这些以及更多都是来自环境的值,在这种情况下,我们将从环境中读取视图的表示方式。

视图的呈现模式仅包含两个数据,但两者都很有用:一个用于存储视图当前是否显示在屏幕上的属性,以及一种让我们立即关闭视图的方法。

要进行尝试,请将此属性添加到中SecondView,该属性将创建一个称为的属性,该属性presentationMode附加到存储在应用程序环境中的演示模式变量中:

1
@Environment(\.presentationMode) var presentationMode

现在,SecondView用以下按钮替换文本视图:

1
2
3
Button("Dismiss") {
self.presentationMode.wrappedValue.dismiss()
}

加入的wrappedValue在那里是必需的,因为presentationMode实际上是有约束力的,因此它可以由系统自动更新-我们需要里面挖检索实际演示模式为我们关闭该视图。

无论如何,有了该按钮,你现在应该可以通过按按钮显示和隐藏工作表。

使用onDelete()删除项目

SwiftUI为我们提供了onDelete()修饰符,供我们用来控制如何从集合中删除对象。实际上,这几乎与List和一起使用ForEach:我们创建一个使用表示的行的列表ForEach,然后附加onDelete()到该ForEach行,以便用户可以删除不需要的行。

这是SwiftUI代表我们完成大量工作的另一个地方,但是你会看到它确实有一些有趣的怪癖。

首先,让我们构造一个可以使用的示例:一个显示数字的列表,每当我们点击按钮时,都会出现一个新数字。这是该代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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("Add Number") {
self.numbers.append(self.currentNumber)
self.currentNumber += 1
}
}
}
}

现在,你可能会认为ForEach不需要-该列表由完全动态的行组成,因此我们可以这样编写:

1
2
3
List(numbers, id: \.self) {
Text("\($0)")
}

那也可以,但是这是我们的第一个怪癖:onDelete()修饰符仅存在于上ForEach,因此,如果我们希望用户从列表中删除项目,则必须将项目放在内ForEach。当我们只有动态行时,这确实意味着少量的额外代码,但是从另一方面来说,这意味着创建仅可以删除某些行的列表会更容易。

为了进行onDelete()工作,我们需要实现一个方法,该方法将接收type的单个参数IndexSet。这有点像一组整数,只不过它是经过排序的,它只是告诉我们ForEach应该删除的所有项目的位置。

因为我们ForEach是完全由单个数组创建的,所以实际上我们可以直接将索引集直接传递给我们的numbers数组–它具有remove(atOffsets:)接受索引集的特殊方法。

因此,ContentView现在添加此方法:

1
2
3
func removeRows(at offsets: IndexSet) {
numbers.remove(atOffsets: offsets)
}

最后,我们可以ForEach通过将它修改为以下方法,告诉SwiftUI在要从中删除数据时调用该方法:

1
2
3
4
ForEach(numbers, id: \.self) {
Text("\($0)")
}
.onDelete(perform: removeRows)

现在继续运行你的应用程序,然后添加一些数字。准备就绪后,从右向左在列表中的任何行上滑动,你会发现出现一个删除按钮。你可以点击它,也可以通过进一步滑动来使用iOS的滑动来删除功能。

考虑到这很容易,我认为结果确实很好。但是SwiftUI还有另外一个技巧:我们可以在导航栏中添加“编辑/完成”按钮,这使用户可以更轻松地删除几行。

首先,将包裹在VStack中NavigationView,然后将此修饰符添加到中VStack:

1
.navigationBarItems(leading: EditButton())

这实际上就是所有的一切–如果运行该应用程序,你会看到可以添加一些数字,然后点按“编辑”以开始删除这些行。准备就绪后,点按完成以退出编辑模式。鉴于代码花费很少,还不错!

使用UserDefault存储

可以说,网站和应用程序之间的最大区别在于它们对用户数据的处理方式。一方面,网站会通过跟踪Cookie,投放再营销广告并观看我们的举动来尽最大努力侵犯隐私,因此很少有用户希望通过更多数据来信任他们。另一方面,我们非常希望应用程序能够存储我们的数据–我们希望它们能够存储数据,而如果每个使用GDPR启动的应用程序“我们都可以为你提供Cookie呢?”会很奇怪。注意。

因此,iOS为我们提供了几种读取和写入用户数据的方式也就不足为奇了,我想在这里看看其中两种。

第一个称为UserDefaults,它使我们可以存储直接附加到我们应用程序的少量用户数据。“ small”没有具体的数字,但是请记住,存储在其中的所有内容UserDefaults都会在应用启动时自动加载-如果在其中存储很多,则应用启动速度会变慢。为了给你一个至少一个主意,你应该在此存储不超过512KB的目标。

提示:如果你要考虑“ 512KB?是多少那?” 然后让我给你一个粗略的估计:它的内容与到目前为止你在本书中阅读的所有章节一样多。

UserDefaults 非常适合存储用户设置和其他重要数据-你可能会跟踪用户上次启动该应用程序的时间,他们上次阅读的新闻报道或其他被动收集的信息。

但是,有一个陷阱:它是字符串类型。这有点像个玩笑的名字,因为“强类型”表示像Swift这样的类型安全语言,其中每个常量和变量都具有特定类型,例如Int或String,而“字符串类型”表示某些代码在它们所处的地方使用字符串可能会引起问题。

足够的聊天-让我们看一些代码。这是一个带有按钮的视图,该视图显示拍子计数,并在每次点击该按钮时递增计数:

1
2
3
4
5
6
7
8
9
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()方法可以接受任何类型的数据-整数,布尔值,字符串等。
我们在此数据上附加一个字符串名称,在本例中为“ Tap”键。就像常规的Swift字符串一样,此键区分大小写,并且很重要–我们需要使用相同的键从中读取数据UserDefaults。
说回读数据,而不是从tapCount设置为0 开始,相反,我们应该UserDefaults像这样使它从回读值:

1
@State private var tapCount = UserDefaults.standard.integer(forKey: "Tap")

请注意,它如何使用完全相同的键名,以确保它读取相同的整数值。

继续尝试一下该应用程序,看看你的想法–你应该能够轻按几次该按钮,回到Xcode,再次运行该应用程序,然后查看确切的数字。

有两件事情你不能在代码中看到的,但两者的事情。首先,如果我们没有设置“ Tap”键,会发生什么?第一次运行应用程序时会是这种情况,但是正如你刚刚看到的那样,它可以正常工作–如果找不到密钥,它将仅发送回0。

有时使用默认值(例如0)会有所帮助,但有时可能会造成混淆。例如,使用布尔值时,如果boolean(forKey:)找不到所需的键,则返回false ,但是,false是你自己设置的值,还是意味着根本没有值?

其次,iOS需要花费一些时间将你的数据写入永久存储-才能将更改实际保存到设备中。他们不会立即写更新,因为你可能会背对背进行更新,因此他们会等待一段时间,然后立即写出所有更改。如何多少时间是另一个我们不知道号码,但几秒钟应该这样做。

结果,如果你点击该按钮,然后从Xcode快速重新启动该应用程序,你会发现最近的点击计数未被保存。过去曾经有一种强制立即写入更新的方法,但是这毫无意义–即使用户在做出选择后立即开始终止应用程序,你的默认数据也会立即写入,因此不会丢失任何数据。

使用 Codeable 归档对象

UserDefaults 对于存储整数和布尔值之类的简单设置非常有用,但是对于复杂数据(例如自定义Swift类型),我们需要做更多的工作。

这是User我们可以使用的简单数据结构:

struct User {
var firstName: String
var lastName: String
}
它有两个字符串,但并不特殊-它们只是一段文本。整数(普通的旧数字),布尔(真或假)和Double(普通的旧数字,只是在其中的某个点有一个)也是如此。即使是这些值的数组和字典也很容易想到:一个字符串,然后是另一个,然后是第三个,依此类推。

当使用这样的数据时,Swift给我们提供了一个很棒的协议,称为Codable:一种专门用于归档和取消归档数据的协议,这是一种“将对象转换为纯文本然后再次转换”的奇特方法。

我们将Codable在未来的项目中进行更多的研究,但是目前我们的需求很简单:我们想要归档一个自定义类型,以便我们可以将其放入其中UserDefaults,然后在从中退出时对其进行归档UserDefaults。

当使用仅具有简单属性的类型(字符串,整数,布尔值,字符串数组等)时,支持归档和取消归档的唯一要做的就是向添加一致性Codable,如下所示:

1
2
3
4
struct User: Codable {
var firstName: String
var lastName: String
}

Swift会自动为我们生成一些代码,这些代码将User根据需要为我们存档和取消存档实例,但是我们仍然需要告诉Swift 何时存档以及如何处理数据。

该过程的这一部分由称为的新类型提供支持JSONEncoder。它的工作是获取符合条件的东西,Codable然后以JavaScript对象表示法(JSON)发送该对象-该名称暗示它特定于JavaScript,但实际上,我们都使用它,因为它是如此之快和简单。

该Codable协议不需要我们使用JSON,实际上其他格式也可以使用,但这是迄今为止最常见的格式。在这种情况下,我们实际上并不在乎使用哪种数据,因为它们只会存储在中UserDefaults。

要将user数据转换为JSON数据,我们需要在上调用encode()方法JSONEncoder。这可能会引发错误,因此应使用try或try?巧妙地调用它。例如,如果我们有一个属性来存储User实例,如下所示:

@State private var user = User(firstName: “Taylor”, lastName: “Swift”)
然后,我们可以创建一个按钮,将用户存档并保存为UserDefaults:

1
2
3
4
5
6
7
Button("Save User") {
let encoder = JSONEncoder()

if let data = try? encoder.encode(self.user) {
UserDefaults.standard.set(data, forKey: "UserData")
}
}

该data常数是一种新的数据类型,可能会引起混淆Data。它旨在存储你可以想到的任何类型的数据,例如字符串,图像,zip文件等。不过,在这里,我们只关心它是可以直接写入的数据类型之一UserDefaults。

当我们返回另一种方式时(当我们拥有JSON数据并且想要将其转换为Swift Codable类型时),我们应该使用JSONDecoder而不是JSONEncoder(),但是过程大致相同。

这使我们进入了项目概述的末尾,因此继续进行,将你的项目重置为其初始状态即可进行构建。

参考资料

查看下一天的SwiftUI学习笔记

关于100days英文课程