个人账本: 介绍
我们接下来的两个项目将开始把你的SwiftUI技能推向基础之外,因为我们将探索具有多个屏幕、加载和保存用户数据以及具有更复杂用户界面的应用程序。
在这个项目中,我们将构建 iExpense,它是一个将个人成本和业务成本分开的费用跟踪工具。它的核心是一个带有表单的应用程序(你花了多少钱?)还有一个清单(这是你花的钱),但是为了完成这两件事,你需要学习如何:
- 显示并关闭第二个数据屏幕。
- 从列表中删除行。
- 保存和加载用户数据。
……还有更多。
有很多事情要做,所以让我们开始:使用单视图应用程序模板创建一个新的iOS应用程序,命名为“iExpense”。我们将在主项目中使用它,但首先让我们更仔细地看一下这个项目所需的新技术…
创建可以删除的列表
在此项目中,我们需要一个可以显示一些费用的列表,以前,我们将使用对象的@State
数组来完成此操作。不过,在这里,我们将采用另一种方法:我们将创建一个Expenses
类,该类将使用@ObservedObject
附加到我们的列表中。
听起来好像我们有点复杂化了,但这实际上使事情变得容易得多,因为我们可以加载Expenses
类并无缝地保存自身——如您所见,它几乎是不可见的。
首先,我们需要确定什么是支出——我们要存储什么?在这种情况下,将涉及三件事:商品名称(无论是公司名称还是个人名称)以及其费用(以整数表示)。
稍后我们将对此进行更多添加,但是现在我们可以使用单个ExpenseItem
结构体来表示所有内容。您可以将其放入一个名为ExpenseItem.swift
的新Swift文件中,但不必这样做,只要您愿意,也可以将其放入ContentView.swift
中,只要不将其放入ContentView
结构体中即可。
无论放在哪里,都可以使用以下代码:
struct ExpenseItem {
let name: String
let type: String
let amount: Int
}
既然我们已经有了代表单一费用的东西,那么下一步就是创建一些东西来将这些费用项目的数组存储在单个对象中。这需要符合ObservableObject
协议,并且我们还将使用@Published
来确保每当对items
数组进行修改时都会发送更改公告。
与ExpenseItem
结构一样,这将从简单开始,稍后我们将对其进行添加,因此,请立即添加此新类:
class Expenses: ObservableObject {
@Published var items = [ExpenseItem]()
}
这样就完成了主视图所需的所有数据:我们有一个结构体来表示单个费用项目,并有一个类来存储所有这些项目的数组。
现在,通过我们的SwiftUI视图将其付诸实践,以便我们实际上可以在屏幕上看到我们的数据。我们大部分的视图只是一个List
,显示费用中的项目,但是由于我们希望用户删除他们不再想要的项目,我们不能只使用简单的List
——我们需要在列表中使用ForEach
,因此我们可以访问onDelete()
修饰符。
首先,我们需要在视图中添加@ObservedObject
属性,该属性将创建Expenses
类的实例:
@ObservedObject var expenses = Expenses()
记住,在这里使用@ObservedObject
要求SwiftUI监视对象是否有任何更改公告,因此,只要我们的@Published
属性之一发生更改,视图就会刷新其body
。
其次,我们可以将该Expenses
对象与NavigationView
,List
和ForEach
一起使用,以创建基本布局:
NavigationView {
List {
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
}
.navigationBarTitle("iExpense")
}
这告诉ForEach
通过其名称唯一标识每个费用项目,然后将其名称打印出来作为列表行。
在完成之前,我们将在简单的布局中添加另外两项内容:添加新项目以进行测试的功能,以及通过滑动删除项目的功能。
我们将允许用户尽快添加自己的商品,但是在继续之前,请务必检查我们的列表是否运作良好。因此,我们将添加一个导航栏按钮,以添加示例ExpenseItem
实例供我们使用——现在将此修饰符添加到列表中:、
.navigationBarItems(trailing:
Button(action: {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
self.expenses.items.append(expense)
}) {
Image(systemName: "plus")
}
)
这使我们的应用程序栩栩如生:您可以立即启动它,然后反复按 + 按钮以添加大量测试费用项。
现在我们可以添加费用了,我们还可以添加代码以删除费用。这意味着添加一种方法,该方法能够删除列表项的IndexSet
,然后将其直接传递到我们的expenses
数组:
func removeItems(at offsets: IndexSet) {
expenses.items.remove(atOffsets: offsets)
}
并将其附加到SwiftUI,我们向ForEach
添加onDelete()
修饰符,如下所示:
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)
继续并立即运行该应用程序,按 + 几次,然后滑动以删除行。
现在,请尝试仔细查看。你注意到了什么?您应该看到添加项目的效果很好,但是删除它们的行为却有些奇怪:在第一行上滑动一点,然后点击其“删除”按钮;您应该看到该行像往常一样滑回原位,然后删除了列表末尾的项目。
这是怎么回事?好吧,事实证明,我们对SwiftUI撒了谎,而这种谎言又回来引起了问题……
在SwiftUI中使用可标识的项目
当我们在SwiftUI中创建静态视图时——当我们对VStack
进行硬编码,然后对TextField
,然后对Button
进行硬编码时——SwiftUI可以准确地看到我们所拥有的视图,并能够对其进行控制,为其设置动画等。但是,当我们使用List
或ForEach
制作动态视图时,SwiftUI需要知道如何唯一地标识每个项目,否则它将无法比较视图层次结构以找出发生了什么变化。
在我们当前的代码中,我们有:
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)
用英语来说,这意味着“为费用项目中的每个项目创建一个新行,并由其名称唯一标识,在行中显示该名称,并调用removeItems()
方法将其删除。”
然后,我们有以下代码:
Button(action: {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
self.expenses.items.append(expense)
}) {
Image(systemName: "plus")
}
每次按下该按钮,都会在我们的列表中添加测试费用,因此我们可以确保添加和删除工作正常。
你看到问题了吗?
每次创建费用项目示例时,我们都使用名称“ Test”,但我们还告诉SwiftUI,它可以将费用名称用作唯一标识符。因此,当我们运行代码并删除一个项目时,SwiftUI会先查看数组——[“Test”,“ Test”,“Test”,“Test”]
——然后再查看数组[“Test”,“ Test”,“Test”]
并不能真正告诉发生了什么变化。发生了某些变化,因为一项已消失,但是SwiftUI无法确定哪一项。结果,它采用了最简单的选项,只是从表中删除了最后一个。
这代表我们一个逻辑错误:我们的代码很好,并且在运行时不会崩溃,但是我们采用了错误的逻辑来获得最终结果——当某些东西它不是唯一时,我们已经告诉SwiftUI,那个是唯一的标识符。
为了解决这个问题,我们需要更多地考虑我们的ExpenseItem
结构体。现在,它具有三个属性:name
, type
, 和 amount
。该名称本身在实践中可能是唯一的,但也可能不是唯一的。一旦用户两次输入“午餐”,我们就会开始遇到问题。我们也许可以尝试将名称,类型和数量组合到一个新的计算属性中,但是即使如此,我们也只是在延迟它发生的时间,它仍然不是真正的唯一。
明智的解决方案是在ExpenseItem
中添加唯一的内容,例如我们手动分配的ID号。那会起作用,但这确实意味着跟踪我们分配的最后一个号码,因此我们也不会在其中使用重复项。
实际上,有一个更简单的解决方案,它称为UUID
——“通用唯一标识符”的缩写,如果听起来不唯一,我不确定会怎么做。
UUID是较长的十六进制字符串,例如:08B15DB4-2F02-4AB8-A965-67A9C90D8A44。因此,这是八位数字,四位数字,四位数字,四位数字,然后是十二位数字,其中唯一的要求是,第三块的第一个数字必须为4。如果减去固定的4,我们最终得到31个数字,每个数字可以是16个值中的一个。如果十亿年中每秒产生1个UUID,那么我们获得重复值得可能性很低。
现在,我们可以更新ExpenseItem
以使其具有如下UUID
属性:
struct ExpenseItem {
let id: UUID
let name: String
let type: String
let amount: Int
}
那会起作用。但是,这也意味着我们需要手动生成一个UUID
,然后加载并保存UUID
以及其他数据。因此,在这种情况下,我们将要求Swift为我们自动生成一个UUID
,如下所示:
struct ExpenseItem {
let id = UUID()
let name: String
let type: String
let amount: Int
}
现在,我们无需担心费用项目的id
值——Swift将确保它们始终是唯一的。
有了这个,我们现在可以修复ForEach
,如下所示:
ForEach(expenses.items, id: \.id) { item in
Text(item.name)
}
如果您现在运行该应用程序,将会看到我们的问题已修复:SwiftUI现在可以准确地看到删除了哪个费用项目,并将正确地动画化所有内容。
不过,我们尚未完成此步骤。相反,我希望您修改ExpenseItem
以使其符合名为Identifiable
的新协议,如下所示:
struct ExpenseItem: Identifiable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
我们要做的就是将Identifiable
添加进去中,仅此而已。这是Swift内置的协议之一,表示“可以唯一地标识此类型”。它只有一个要求,那就是必须有一个名为id
的属性,其中包含唯一的标识符。我们只是添加了它,因此我们不需要做任何额外的工作——我们的类型符合Identifiable
就可以了。
现在,您可能想知道为什么要添加它,因为我们的代码以前运行良好。好吧,因为现在我们的费用项目可以保证是唯一可识别的,所以我们不再需要告诉ForEach
标识符要使用哪个属性——它知道将有一个id
属性并且它将是唯一的,因为这就是 Identifiable
协议的关键所在。
因此,作为此更改的结果,我们可以再次将ForEach
修改为:
ForEach(expenses.items) { item in
Text(item.name)
}
好多了!
用新视图共享被观察的对象
可以在多个SwiftUI视图中使用符合ObservableObject
的类,并且当类的已发布属性更改时,所有这些视图都将更新。
在此应用中,我们将设计一个视图专门用于添加新的费用项目。当用户准备就绪时,我们会将其添加到Expenses
类中,这将自动导致原始视图刷新其数据,以便显示费用项目。
要创建新的SwiftUI视图,可以按 Cmd+N
或转到“File”菜单,然后选择 New > File...
。无论哪种方式,都应该在“User Interface”类别下选择“SwiftUI View”,然后将文件命名为AddView
。确保将文件与其他代码一起保存在“ iExpense”目录中。一切都很好,Xcode应该向您显示新视图,准备进行编辑。
与其他视图一样,我们在AddView
的第一步很简单,我们将对其进行添加。这意味着我们将为费用名称和金额添加文本字段,为类型添加选择器,所有字段都包裹在表格和导航视图中。
到目前为止,这对您来说都是写过时的东西,所以让我们进入代码:
struct AddView: View {
@State private var name = ""
@State private var type = "Personal"
@State private var amount = ""
@ObservedObject var expenses: Expenses
static let types = ["Business", "Personal"]
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name)
Picker("Type", selection: $type) {
ForEach(Self.types, id: \.self) {
Text($0)
}
}
TextField("Amount", text: $amount)
.keyboardType(.numberPad)
}
.navigationBarTitle("Add new expense", displayMode: .inline)
}
}
}
我们待会儿再谈这些代码,但是首先让我们向ContentView
添加一些代码,以便在点击 + 按钮时可以显示AddView
。
为了将AddView
呈现为新视图,我们需要对ContentView
进行三处更改。首先,我们需要某种状态来跟踪是否显示AddView
,因此现在将其添加为属性:
@State private var showingAddExpense = false
接下来,我们需要告诉SwiftUI使用该布尔值作为显示表格的条件——一个弹出窗口。这是通过将sheet()
修饰符附加到视图层次结构的某个位置来完成的。您可以根据需要使用列表,但NavigationView
也可以使用。无论哪种方式,都可以将此代码作为修饰符添加到ContentView
中的一个视图中:
.sheet(isPresented: $showingAddExpense) {
// show an AddView here
}
第三步是在sheet
上放一些东西。通常,这只是您要显示的视图类型的一个实例,如下所示:
.sheet(isPresented: $showingAddExpense) {
AddView()
}
不过,在这里,我们还需要更多。您会看到,我们的内容视图中已经包含了费用属性,在AddView
中,我们将编写代码来添加费用项目。我们不想在AddView
中创建Expenses
类的第二个实例,而是希望它共享ContentView
中的现有实例。
因此,我们要做的是向AddView
添加属性以存储Expenses
对象。它不会在那里创建对象,只是说它会存在。请将此属性添加到AddView
:
@ObservedObject var expenses: Expenses
现在,我们可以将现有的Expenses
对象从一个视图传递到另一个视图——它们将共享同一个对象,并且都将监视它的更改。将·ContentView·中的·sheet()·修饰符修改为这样:
.sheet(isPresented: $showingAddExpense) {
AddView(expenses: self.expenses)
}
由于以下两个原因,我们尚未完成此步骤:原因是我们的代码无法编译,即使编译了代码也无法正常工作,因为我们的按钮不会触发sheet
。
发生编译失败的原因是,当我们制作新的SwiftUI视图时,Xcode还添加了预览提供程序,因此我们可以在编码时查看视图的设计。如果您在AddView.swift
的底部找到它,您会发现它尝试创建一个AddView
实例,而没有为Expenses
属性提供值。
不再允许这样做,但是我们可以改为传入一个虚拟值,如下所示:
struct AddView_Previews: PreviewProvider {
static var previews: some View {
AddView(expenses: Expenses())
}
}
第二个问题是我们实际上没有任何代码可以显示工作表,因为现在ContentView
中的+按钮会增加测试费用。幸运的是,修复很简单——只需用代码替换现有操作即可切换我们的showingAddExpense
布尔值,如下所示:
Button(action: {
self.showingAddExpense = true
}) {
Image(systemName: "plus")
}
如果现在运行该应用程序,则整个工作表应按预期工作——从ContentView
开始,点击 + 按钮以弹出一个AddView
,您可以在其中输入各个字段,然后可以滑动以关闭它。
使用UserDefaults保存更改
至此,我们的应用程序的用户界面已正常运行:您已经看到我们可以添加和删除项目,现在,我们有了一个工作表,其中显示了用于创建新费用的用户界面。但是,该应用程序远不能正常工作:AddView
中放置的任何数据都将被完全忽略,即使不忽略它们,也不会在以后运行该应用程序时将其保存。
我们将按顺序解决这些问题,首先要对AddView
中的数据进行实际处理。我们已经具有存储表单中值的属性,并且之前添加了一个属性来存储从ContentView
传入的Expenses
对象。
我们需要将这两件事放在一起:我们需要一个按钮,在点击该按钮时,会从我们的属性中创建一个ExpenseItem
并将其添加到expenses
项目中。我们的ExpenseItem
结构体的Amount
为整数,这意味着我们需要对数量的字符串值进行一些类型转换。
在AddView
中的navigationBarTitle()
下添加此修饰符:
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
self.expenses.items.append(item)
}
})
尽管我们还有很多工作要做,但我建议您立即运行该应用程序,因为它实际上已经集成在一起了——您现在可以显示添加视图,输入一些详细信息,按"Save'',然后滑动以关闭,然后您将看到自己的应用程序列表中的新项目。这意味着我们的数据同步运行良好:两个SwiftUI视图都从相同的费用项目列表中读取。
现在,尝试再次启动该应用程序,您将立即遇到第二个问题:您添加的任何数据都不会存储,这意味着每次重新启动该应用程序时,所有内容都会空白。
这显然是非常糟糕的用户体验,但是由于我们将Expense
作为单独的类,因此实际上并没有那么难解决。
我们将利用四种重要技术来帮助我们以干净的方式保存和加载数据:
-
Codable
协议,这将使我们能够存档所有准备存储的现有费用项目。 -
UserDefaults
,这将使我们保存和加载该存档数据。 -
Expenses
类的自定义初始化程序,因此当我们创建它的实例时,我们将从UserDefaults
中加载所有保存的数据 -
Expenses
的items
属性上的didSet
属性观察器,因此无论何时添加或删除项目,我们都会记录更改。
让我们先解决写入数据的问题。我们在Expenses
类中已经具有此属性:
@Published var items: [ExpenseItem]
在这里,我们存储了所有已创建的费用项目结构体,并且在这里,我们将附加属性观察器以在发生更改时将其记录。
这总共需要四个步骤:我们需要创建一个JSONEncoder
实例,该实例将完成将数据转换为JSON的工作,我们要求尝试对我们的items
数组进行编码,然后可以使用键“Items”将其写入UserDefaults
。
修改Items
属性为如下:
@Published var items: [ExpenseItem] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}
现在,如果您一直遵循课程,您会发现代码实际上并没有编译。而且,如果您密切关注,您会注意到我说过此过程需要四个步骤,而只列出了三个。
问题是编码器.encode()
方法只能存档符合Codable
协议的对象。请记住,遵循Codable
是要求编译器为我们生成能够处理归档和取消归档对象的代码的方法,如果我们不为此添加代码,则我们的代码将无法生成。
有用的是,除了将Codable
添加到ExpenseItem
之外,我们不需要做任何其他工作,像这样:
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
Swift已经为ExpenseItem
的UUID
,String
和Int
属性遵循了Codable
协议,因此,它能使ExpenseItem
自动符合我们的要求。
进行此更改后,我们需要确保我们代码可以在用户添加项目时将其保存。但是,它本身并不有效:数据可能会保存,但是在应用重新启动时不会再次加载。
为了解决这个问题,并使我们的代码再次编译——我们需要实现一个自定义初始化程序,它将:
- 尝试从
UserDefaults
中读取“Items”键对应的内容。 - 创建一个
JSONDecoder
实例,它是JSONEncoder
的对应对象,它使我们能够从JSON数据转到Swift对象。 - 要求解码器将我们从
UserDefaults
接收到的数据转换为ExpenseItem
对象的数组。 - 如果可行,将结果数组分配给
items
并退出。 - 否则,将
items
设置为空数组。
现在将此初始化程序添加到Expenses
类中:
init() {
if let items = UserDefaults.standard.data(forKey: "Items") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([ExpenseItem].self, from: items) {
self.items = decoded
return
}
}
self.items = []
}
该代码的两个关键部分是data(forKey: "Items")
行,该行尝试读取“Items”中的内容作为Data
对象,然后是try? coder.decode([ExpenseItem].self, from: items)
行,它实际上完成了将Data
对象解归档为ExpenseItem
对象数组的工作。
初次看到[ExpenseItem].self
时,通常会花点时间思考.self
是什么意思?好吧,如果我们只是使用[ExpenseItem]
,Swift会想知道我们的意思——我们是否要复制该类?我们是否打算引用静态属性或方法?我们是否打算创建类的实例?为避免混淆——意思是说我们指的是类型本身,即类型对象——我们在其后编写.self
。
现在,我们已经完成了加载和保存,您应该可以使用该应用程序了。不过,还没有完成——请添加一些最后的修饰语!
最后的润色
如果您尝试使用该应用程序,很快就会发现它存在两个问题:
- 增加费用并不能隐藏
AddView
,它只是停留在那里。 - 添加费用后,您实际上看不到有关此费用的任何详细信息。
在结束这个项目之前,我们先修复一下这些问题,以使整个过程更加美观。
首先,通过保存对视图呈现方式的引用来关闭AddView
,然后在适当的时候在其上调用dismiss()
。展示模式presentationMode
由视图环境控制,并链接到表单的isPresented
参数——我们将布尔值设置为true
以显示AddView
,但是当我们在展示模式下调用dismiss()
时,环境会将其翻转回false
。
首先将此属性添加到AddView
:
@Environment(\.presentationMode) var presentationMode
您会注意到我们没有为此指定类型——借助@Environment
属性包装器,Swift可以找出它的类型。
接下来,当我们希望视图自行关闭时,我们需要调用presentationMode.wrappedValue.dismiss()
。这将导致ContentView
中的showingAddExpense
布尔值返回false
,并隐藏AddView
。我们在AddView
中已经有一个保存按钮,它可以创建一个新的费用项目并将其追加到我们的现有费用中,因此,请直接在以下行中添加:
self.presentationMode.wrappedValue.dismiss()
这就解决了第一个问题,仅剩下第二个问题:我们显示每个费用项目的名称,仅此而已。这是因为我们列表中的ForEach
太简单了:
ForEach(expenses.items) { item in
Text(item.name)
}
我们将用另一个包含堆栈的一个堆栈替换它,以确保所有信息在屏幕上看起来都很好。内部堆栈将是显示费用名称和类型的VStack
,然后周围是左侧有VStack
的HStack
,然后是分隔符,然后是费用金额。这种布局在iOS上很常见:左侧的标题和副标题,右侧的更多信息。
用以下内容替换ContentView
中现有的ForEach
:
ForEach(expenses.items) { item in
HStack {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.type)
}
Spacer()
Text("$\(item.amount)")
}
}
现在最后一次运行该程序并尝试一下——我们完成了!
译自 Hacking with iOS: SwiftUI Edition - iExpense
iExpense: Introduction
Building a list we can delete from
Working with Identifiable items in SwiftUI
Sharing an observed object with a new view
Making changes permanent with UserDefaults
Final polish
使用Codable归档Swift对象 | Hacking with iOS: SwiftUI Edition | iExpense 项目挑战 |
---|
赏我一个赞吧~~~