百日学 Swift(Day 36) – 项目 7 :第 1 部分

百日学 Swift(Day 36) – Project 7, part one(项目 7 :第 1 部分)

1. iExpense: Introduction(项目介绍)

接下来的两个项目我们将探索具有多个屏幕,加载和保存用户数据以及具有更复杂的用户界面的应用程序,这些都是在基础知识之上拓展的SwiftUI技能。

本项目将构建 iExpense 应用程序,这是一个费用跟踪器,可以将个人成本与业务成本区分开。它的核心是一个表单(您花了多少钱?)和一个列表(这里是您花了多少钱),但是要完成这两件事,您需要学习:

  • 显示和关闭第二个数据屏幕。
  • 从列表中删除行
  • 保存和加载用户数据

以及更多的内容。

有很多事情要做,所以让我们开始吧:使用 Single View App 模板创建一个新的 iOS 应用程序,将其命名为“ iExpense ”。我们将把它用于主要项目,但首先让我们仔细看一下该项目所需的新技术…

2. Why @State only works with structs(为什么 @State 仅适用于结构体)

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–现在让我们看一下……

3. Sharing SwiftUI state with @ObservedObject(使用 @ObservedObject 共享 SwiftUI 状态)

如果想使用一个类与 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类有两个属性:firstNamelastName。每当这两个更改中的任何一个更改时,我们都希望通知正在观看这个类的所有视图发生了更改,以便可以重新加载它们。可以使用@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属性包装器创建类的实例。

最终结果是可以将状态存储在一个外部对象中,甚至更好的是,现在可以在多个视图中使用该对象并将它们都指向相同的值。

4. Showing and hiding views(显示和隐藏视图)

在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实际上是个绑定,因此它可以由系统自动更新,而我们只需要挖掘其中,检索实际表现模式来关闭该视图。

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

5. Deleting items using onDelete()(使用 onDelete() 删除条目)

SwiftUI 提供了onDelete()修饰器,用来控制如何从集合中删除对象。在实践中几乎总是和ListForEach一起使用:我们创建一个使用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())

6. Storing user settings with UserDefaults(使用 UserDefaults 存储用户设定)

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

GDPR

《通用数据保护条例》(General Data Protection Regulation,简称GDPR)为欧洲联盟的条例,前身是欧盟在1995年制定的《计算机数据保护法》。

2018年5月25日,欧洲联盟出台《通用数据保护条例》

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

第一个称为UserDefaults,它使我们可以存储直接附加到应用程序的少量用户数据。“ 少量”没有具体的数字,不过,存储在UserDefaults中的所有内容都会在应用启动时自动加载——如果内容很多,则应用启动速度会变慢。建议不超过512KB。

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

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

这是一个带有按钮的视图,该视图显示点击计数,并在每次点击该按钮时递增计数:

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")

在这一行代码中,可以看到三件事:

  1. 我们需要使用UserDefaults.standard。这是UserDefaults附加到我们应用程序的内置实例,但是在更高级的应用程序中,您可以创建自己的实例。例如,如果要在多个应用程序扩展中共享默认设置,则可以创建自己的UserDefaults实例。
  2. 有一个set()方法可以接受任何类型的数据——整数,布尔值,字符串等。
  3. 我们在此数据上附加一个字符串名称,在本例中为“ Tap”键。就像常规的Swift字符串一样,此键区分大小写,并且很重要——我们需要使用相同的键从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 需要花费一些时间将数据写入永久存储,具体多少时间我们不知道,但几秒钟应该这样够了。

7. Archiving Swift objects with Codable(使用 Codable 归档 Swift 对象)

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()方法。这可能会引发错误,因此应使用trytry?巧妙地调用它。例如,如果我们有一个属性来存储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(),但是过程大致相同。

你可能感兴趣的:(#,第,4,阶段(Days,36-48):项目,7-9)