SwiftUI 学习笔记 37:项目 7-2 消费记录APP
今天,你会使用来构建一个完整的应用程序@ObservedObject,@Published,sheet(),Codable,UserDefaults,等等。我意识到这似乎很多,但我希望你尝试考虑后台发生的所有事情:
@Published 自动发布变更公告。
@ObservedObject 监视这些公告并使用该对象刷新所有视图。
sheet() 观看我们指定的条件并自动显示或隐藏视图。
Codable 可以将Swift对象转换为JSON,然后几乎不需要我们提供任何代码。
UserDefaults 可以读取和写入数据,以便我们可以更即时地保存设置。
是的,我们需要编写代码以将这些内容放置在适当的位置,但是由于删除了许多样板代码,因此剩下的内容非常出色。正如法国作家和诗人安托万·德·圣艾修伯里(Antoine de Saint-Exupery)曾经说过的那样:“完美无缺,只有在没有更多可添加的东西时才能实现,而只有在没有更多需要补充的时候才能实现。”
项目名:iExpense
建立我们可以从中删除的列表
在此项目中,我们需要一个可以显示一些费用的列表,以前,我们将使用@State对象数组来完成此操作。不过,在这里,我们将采用另一种方法:我们将使用创建一个Expenses将附加到列表的类@ObservedObject。
听起来好像我们有点复杂化了,但是实际上这使事情变得容易得多,因为我们可以使Expenses类加载并无缝保存自身–如你所见,它几乎是不可见的。
首先,我们需要确定费用什么是 -我们怎么想它来存储?在这种情况下,这将是三件事:项目的名称(无论是企业还是个人的)以及其成本(整数)。
稍后我们将对其进行更多添加,但是现在我们可以使用单个ExpenseItem结构来表示所有内容。你可以把这个到名为ExpenseItem.swift一个新雨燕的文件,但你并不需要-你可以把这个变成ContentView.swift如果你喜欢,只要你不把它里面的ContentView结构本身。
无论放在哪里,都可以使用以下代码:
1 | struct ExpenseItem { |
既然我们已经有了代表单一费用的东西,那么下一步就是创建一些东西来将这些费用项目的数组存储在单个对象中。这需要符合ObservableObject协议,并且我们还将使用该方法@Published来确保每当items修改数组时都会发送更改声明。
与ExpenseItemstruct一样,这将从简单开始,稍后我们将对其进行添加,因此,现在添加此新类:
1 | class Expenses: ObservableObject { |
这样就完成了主视图所需的所有数据:我们有一个结构来表示单个费用项目,并有一个类来存储所有这些项目的数组。
现在,让我们将它放入行动与我们的SwiftUI观点,所以我们实际上可以在屏幕上看到我们的数据。我们的大多数观点的将只是一个List展示在我们的开支项目,而是因为我们希望用户在他们不再想要的,我们不能只用简单删除的项目List-我们需要使用一个ForEach 内部列表,所以我们可以访问该onDelete()修改。
首先,我们需要@ObservedObject在视图中添加一个属性,该属性将创建Expenses类的实例:
@ObservedObject var expenses = Expenses()
请记住,使用@ObservedObject此处要求SwiftUI监视对象是否有任何更改通知,因此,只要我们的@Published属性之一发生更改,视图就会刷新其主体。
其次,我们可以使用Expenses对象有NavigationView,一List,和ForEach,创造我们的基本布局:
1 | NavigationView { |
告诉ForEach,以其名称唯一标识每个费用项目,然后将名称打印为列表行。
在完成之前,我们将向我们的简单布局中添加另外两件事:能够添加用于测试目的的新项目,以及能够通过滑动删除项目的功能。
我们将允许用户尽快添加自己的商品,但是在继续之前,请务必检查我们的列表是否运作良好。因此,我们将添加一个尾随按钮按钮项,该按钮项将添加示例ExpenseItem实例供我们使用–将修饰符添加到List现在:
1 | .navigationBarItems(trailing: |
这使我们的应用程序栩栩如生:你可以立即启动它,然后反复按+按钮以添加大量测试费用。
现在我们可以添加费用了,我们还可以添加代码以删除费用。这意味着添加一种能够删除IndexSet列表项的方法,然后将其直接传递给我们的expenses数组:
1 | func removeItems(at offsets: IndexSet) { |
并将其附加到SwiftUI,我们向,添加了一个onDelete()修饰符ForEach,如下所示:
1 | ForEach(expenses.items, id: \.name) { item in |
继续并立即运行该应用程序,按+几次,然后滑动以删除行。
现在,尝试时要仔细看。你注意到什么?你应该看到添加项目的效果很好,但是删除它们的行为却有些奇怪:在第一行上滑动一点,然后点击其“删除”按钮;你应该看到该行像往常一样滑回原位,然后删除了列表末尾的项目。
这是怎么回事?好吧,事实证明,我们对SwiftUI撒了谎,而这种谎言又回来引起了问题……
在SwiftUI中使用可识别的项目
当我们在SwiftUI中创建静态视图时-当我们对a VStack,然后TextField,a ,然后a 等进行硬编码时,ButtonSwiftUI可以准确地看到我们拥有的视图,并能够对其进行控制,设置动画以及进行更多操作。但是,当我们使用List或ForEach创建动态视图时,SwiftUI需要知道它如何唯一地标识每个项目,否则它无法比较视图层次结构以找出发生了什么变化。
在我们当前的代码中,我们有:
1 | ForEach(expenses.items, id: \.name) { item in |
用英语来说,这意味着“为费用项目中的每个项目创建一个新行,并由其名称唯一标识,在行中显示该名称,并调用removeItems()删除它的方法。”
然后,我们有以下代码:
1 | Button(action: { |
每次按下该按钮,都会在我们的列表中添加测试费用,因此我们可以确保添加和删除工作正常。
你看到问题了吗?
每次创建示例费用项目时,我们都使用名称“ Test”,但我们还告诉SwiftUI,它可以将费用名称用作唯一标识符。所以,当我们的代码运行,我们删除了某个项目,SwiftUI着眼于阵列事先- “测试”,“测试”,“测试”,“测试” -然后在阵列看上去之后 - “测试”,“测试”, “测试”-并不能真正告诉发生了什么变化。发生了一些变化,因为一项已消失,但是SwiftUI不能确定是哪一项。结果,它采用了最简单的选项,只是从表中删除了最后一个。
这代表我们一个逻辑错误:我们的代码很好,并且在运行时不会崩溃,但是我们采用了错误的逻辑来获得最终结果–我们已经告诉SwiftUI,某些东西将是唯一的标识符,当它不是唯一的时。
为了解决这个问题,我们需要更多地考虑我们的ExpenseItem结构。现在,它有三个属性:name,type,和amount。该名称本身在实践中可能是唯一的,但也可能不是唯一的。一旦用户两次输入“午餐”,我们就会开始解决问题。我们也许可以尝试将名称,类型和数量组合到一个新的计算属性中,但是即使如此,我们也只是在延迟不可避免的时间;它仍然不是很独特。
这里的智能解决方案是增加的东西ExpenseItem那是唯一的,如ID号,我们可以指定由手。那会起作用,但这确实意味着跟踪我们分配的最后一个数字,因此我们也不会在其中使用重复项。
其实也有一个简单的解决方案,它叫做UUID-短期的“通用唯一标识符”,如果说没有独特的声音,我不知道该怎么做。
UUID是较长的十六进制字符串,例如:08B15DB4-2F02-4AB8-A965-67A9C90D8A44。因此,这是八位数字,四位数字,四位数字,四位数字,然后是十二位数字,其中唯一的要求是在第三块的第一个数字中有一个4。如果减去固定的4,我们最终得到31个数字,每个数字可以是16个值之一–如果在十亿年的时间里每秒产生1个UUID,则可能有最小的机会产生重复的数字。
现在,我们可以更新ExpenseItem为具有以下UUID属性:
1 | struct ExpenseItem { |
那会起作用。但是,这也意味着我们需要手动生成一个UUID,然后加载并保存UUID以及其他数据。因此,在这种情况下,我们将要求Swift UUID像这样自动为我们生成一个:
1 | struct ExpenseItem { |
现在,我们无需担心id费用项目的价值– Swift将确保它们始终是唯一的。
有了它,我们现在可以修复ForEach,如下所示:
1 | ForEach(expenses.items, id: \.id) { item in |
如果现在运行该应用程序,你将看到我们的问题已修复:SwiftUI现在可以确切地看到删除了哪个费用项目,并将正确地动画化所有内容。
不过,我们尚未完成此步骤。相反,我希望你修改ExpenseItem使其符合名为的新协议Identifiable,如下所示:
1 | struct ExpenseItem: Identifiable { |
我们所做的只是添加Identifiable到协议一致性列表中,仅此而已。这是Swift内置的协议之一,表示“可以唯一地标识此类型”。它只有一个要求,那就是必须有一个名为的属性id,其中包含唯一的标识符。我们只是添加了这些内容,因此我们不需要做任何额外的工作-我们的类型Identifiable就可以了。
现在,你可能想知道为什么要添加它,因为我们的代码以前运行良好。好吧,因为现在可以保证我们的费用项目是唯一可识别的,所以我们不再需要告诉ForEach标识符要使用哪个属性-它知道将有一个id属性并且它将是唯一的,因为这就是Identifiable协议的重点。
因此,由于此更改,我们可以ForEach再次对此进行修改:
1 | ForEach(expenses.items) { item in |
好多了!
以新视图共享观察到的对象
ObservableObject可以在多个SwiftUI视图中使用符合的类,并且当类的已发布属性更改时,所有这些视图都将更新。
在此应用程序中,我们将设计一个专门用于添加新费用项目的视图。当用户准备就绪时,我们会将其添加到我们的Expenses类中,这将自动导致原始视图刷新其数据,以便可以显示费用项目。
要创建新的SwiftUI视图,可以按Cmd + N或转到“文件”菜单,然后选择“新建”>“文件”。无论哪种方式,都应该在“用户界面”类别下选择“ SwiftUI视图”,然后将文件命名为AddView.swift。如果发现该组旁边没有黄色文件夹,请选择该组,然后确保将文件与其他代码一起保存在“ iExpense”目录中。一切都很好,Xcode应该向你显示新视图,可以进行编辑。
与其他视图一样,我们的第一遍AddView将很简单,我们将对其进行补充。这意味着我们将为费用名称和金额添加文本字段,为类型添加选择器,所有字段都包裹在表单和导航视图中。
到目前为止,这对你来说都是个老新闻,因此让我们进入代码:
1 | struct AddView: View { |
我的注释:这里面static是静态属性
我们待会儿再讲到这一点,但是首先让我们添加一些代码,ContentView以便我们可以显示AddView何时点击+按钮。
为了呈现AddView为新视图,我们需要对进行三处更改ContentView。首先,我们需要某种状态来跟踪是否AddView显示,因此现在将其添加为属性:
@State private var showingAddExpense = false
接下来,我们需要告诉SwiftUI使用该布尔值作为显示表格的条件-一个弹出窗口。这是通过将sheet()修饰符附加到视图层次结构的某个位置来完成的。你可以List根据需要使用,但NavigationView效果也不错。无论哪种方式,都可以将此代码作为修饰符添加到以下视图之一ContentView:
1 | .sheet(isPresented: $showingAddExpense) { |
第三步是在纸上放一些东西。通常,这只是你要显示的视图类型的一个实例,如下所示:
1 | .sheet(isPresented: $showingAddExpense) { |
不过,在这里,我们还需要更多。你会看到,我们已经expenses在内容视图中拥有该属性,而在内部AddView我们将要编写代码以添加费用项目。我们不想在中创建该类的第二个实例,而是希望它共享中的现有实例。ExpensesAddViewContentView
因此,我们要做的是添加一个属性AddView来存储Expenses对象。它不会在那里创建对象,只是说它会存在。请将此属性添加到AddView:
@ObservedObject var expenses: Expenses
现在,我们可以将现有Expenses对象从一个视图传递到另一个视图-它们将共享同一个对象,并且都将监视它的更改。在其中修改你的sheet()修饰符ContentView:
1 | .sheet(isPresented: $showingAddExpense) { |
我们还没有完成此步骤后呢,原因有二:我们的代码将无法编译,即使它没有编译它是行不通的,因为我们的按钮不会触发片。
发生编译失败的原因是,当我们制作新的SwiftUI视图时,Xcode还添加了预览提供程序,因此我们可以在编码时查看视图的设计。如果你在AddView.swift的底部找到它,你将看到它尝试创建AddView实例而不提供expenses属性值。
不再允许这样做,但是我们可以只传递一个虚拟值,如下所示:
1 | struct AddView_Previews: PreviewProvider { |
第二个问题是我们实际上没有任何代码可以显示工作表,因为现在+中的按钮ContentView会增加测试费用。幸运的是,此修复很简单–只需用代码替换现有操作即可切换我们的showingAddExpense布尔值,如下所示:
1 | Button(action: { |
如果现在运行该应用程序,则整个工作表应按预期工作-从开始ContentView,点击+按钮调出一个AddView你可以在各个字段中键入的位置,然后可以滑动以将其关闭。
使用UserDefaults永久更改
至此,我们的应用程序的用户界面已正常运行:你已经看到我们可以添加和删除项目,现在,我们有了一个工作表,其中显示了用于创建新费用的用户界面。但是,该应用程序远不能正常工作:放入其中的任何数据都将AddView被完全忽略,即使不忽略它,也仍将无法保存该数据供以后运行该应用程序使用。
我们要解决这些问题,从而,从实际做的东西从数据AddView。我们已经具有存储表单中值的属性,并且之前我们添加了一个属性来存储Expenses从传入的对象ContentView。
我们需要将这两件事放在一起:我们需要一个按钮,在点击该按钮时,会ExpenseItem从我们的属性中创建一个并将其添加到expenses项目中。我们的ExpenseItem结构体的数量为整数,这意味着我们需要从的字符串值进行一些类型转换amount。
navigationBarTitle()在以下位置添加此修饰符AddView:
1 | .navigationBarItems(trailing: Button("Save") { |
尽管我们还有很多工作要做,但我建议你现在运行该应用程序,因为它实际上已经集成在一起了–你现在可以显示添加视图,输入一些详细信息,按“保存”,然后滑动以关闭,你将看到自己的列表中的新项目。这意味着我们的数据同步运行良好:两个SwiftUI视图都从相同的费用项目列表中读取。
现在尝试再次启动该应用程序,你将立即遇到第二个问题:添加的任何数据都不会存储,这意味着每次重新启动该应用程序时,所有内容都会空白。
这显然是非常糟糕的用户体验,但是由于我们拥有Expense一个单独的类,因此实际上并不难解决。
我们将利用四种重要技术来帮助我们以干净的方式保存和加载数据:
该Codable协议将使我们能够将所有现有的费用项目存档以备存储。
UserDefaults,这将使我们保存和加载该存档数据。
Expenses该类的自定义初始化程序,因此当我们创建它的实例时,我们将从中加载所有已保存的数据UserDefaults
一个didSet属性观察者的items财产Expenses,所以,每当一个项目被添加或删除,我们会写出来的变化。
让我们先解决写数据的问题。我们已经在Expenses该类中拥有此属性:
@Published var items: [ExpenseItem]
那是我们存储所有已创建的费用项目结构的地方,这也是我们将附加属性观察器以在发生变化时将其写出的地方。
这总共需要四个步骤:我们需要创建一个实例来JSONEncoder完成将数据转换为JSON的工作,要求我们尝试对items数组进行编码,然后可以UserDefaults使用键“ Items”将其写入。
将该items属性修改为此:
1 | var items: [ExpenseItem] { |
现在,如果你一直遵循,你会发现代码实际上并未编译。而且,如果你一直在密切关注,你会注意到我说这个过程需要四个步骤,而只列出了三个。
问题在于该encoder.encode()方法只能归档符合Codable协议的对象。记住,遵循Codable是要求编译器为我们生成能够处理归档和取消归档对象的代码的方法,如果我们不为此添加一致性,那么我们的代码将无法生成。
很有帮助,我们不需要做的比添加其他任何工作Codable来ExpenseItem,就像这样:
1 | struct ExpenseItem: Identifiable, Codable { |
斯威夫特已经包含Codable了符合项UUID,String和Int的性质ExpenseItem,所以它能够使ExpenseItem自动只要我们要求它符合。
进行此更改后,我们编写了所有代码,以确保在用户添加项目时将其保存。但是,它本身并不有效:数据可能会保存,但是在应用重新启动时不会再次加载。
为了解决这个问题-并使我们的代码再次编译-我们需要实现一个自定义的初始化程序。那将:
尝试从读取“项目”键UserDefaults。
创建的实例JSONDecoder,该实例与之相对应JSONEncoder,使我们从JSON数据转到Swift对象。
要求解码器将接收UserDefaults到的数据转换为ExpenseItem对象数组。
如果可行,将结果数组分配给items并退出。
否则,设置items为空数组。
Expenses现在将此初始化程序添加到类中:
1 | init() { |
该代码的两个关键部分是该data(forKey: “Items”)行,它试图读取“ Items”中的任何内容作为一个Data对象,以及try? decoder.decode([ExpenseItem].self, from: items),它实际上完成了将Data对象归档为对象数组的工作ExpenseItem。
初次见面时,经常会花一点时间,[ExpenseItem].self这.self是什么意思?好吧,如果我们刚刚使用过[ExpenseItem],Swift就会想知道我们的意思–我们是否要复制该类?我们是否打算引用静态属性或方法?我们是否打算创建类的实例?为避免混淆,也就是说我们是指类型本身,即类型对象,我们.self在其后编写。
现在,我们已经完成了加载和保存,你应该可以使用该应用程序了。不过,它尚未完成-让我们添加一些最终修饰!
最终抛光
如果尝试使用该应用程序,很快就会发现它有两个问题:
增加开支并没有消除AddView; 它只是停留在那里。
添加费用后,你实际上看不到有关此费用的任何详细信息。
在结束这个项目之前,让我们修复那些问题,使整个过程看起来更加优美。
首先,AddView通过存储对视图呈现方式的引用来关闭,然后dismiss()在适当时调用该视图。表示模式由视图的环境控制,并链接到isPresented图纸的参数–我们将Boolean设置为true以显示AddView,但当我们调用dismiss()表示模式时,环境会将其翻转回false 。
首先将此属性添加到AddView:
1 | var presentationMode (\.presentationMode) |
你会注意到我们没有为此指定类型-借助@Environment属性包装器,Swift可以找出它的类型。
接下来,presentationMode.wrappedValue.dismiss()当我们希望视图自行关闭时,我们需要调用。这将导致showingAddExpenseBoolean输入ContentView返回false,并隐藏AddView。我们已经有了一个“保存”按钮,AddView可以创建一个新的费用项目并将其追加到我们的现有费用中,因此,请直接在以下行中添加:
1 | self.presentationMode.wrappedValue.dismiss() |
这就解决了第一个问题,仅剩下第二个问题:我们显示每个费用项目的名称,仅此而已。这是因为ForEach对于我们的列表来说,这是微不足道的:
1 | ForEach(expenses.items) { item in |
我们将用另一个堆栈中的堆栈替换它,以确保所有信息在屏幕上看起来都很好。内堆将是一个VStack示出了费用的名称和类型,然后围绕这将是一个HStack与VStack在左边,则间隔物,则该费用金额。这种布局在iOS上很常见:左侧的标题和副标题,右侧的更多信息。
替换现有ForEach在ContentView用这样的:
1 | ForEach(expenses.items) { item in |
现在,最后一次运行该程序并尝试一下-我们完成了!
我的最终代码
ContentView.swift
1 | import SwiftUI |
AddView.swift
1 | import SwiftUI |
参考资料
- 感谢你赐予我前进的力量