译自 Sharing an observed object with a new view
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀
遵循 ObservableObject
的类可以被用在多于一个 SwiftUI 视图,当这个类的 published 属性变化时,所有相关视图都会被更新。
在这个 app 中,我们要设计一个视图,专门用来添加新的花费项。当用户完成新增操作,我们会把新增的花费项添加到 Expenses
类,它会自动触发原来的视图刷新它的数据,这样新的花费项就会显现。
为了创建一个新的 SwiftUI 视图,你既可以点击 Cmd+N ,也可以到文件菜单选择新建 > 文件。不管那种方式,都要在用户接口分类下选择 “SwiftUI View” ,然后命名文件为AddView.swift 。
相对于我们的其他视图, 第一遍的AddView
会比较简单,让我们逐步完善它。我们会添加用于花销项的名字和数量的文本框,以及一个用于类型的 picker ,全部都包在表单和导航视图里面。
这里需要用到的知识对你来说应该都不新鲜了。让我们直接上代码:
struct AddView: View { @State private var name = "" @State private var type = "Personal" @State private var amount = "" 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") } } }复制代码
我们稍晚些时候还会回到上面的代码。现在先让我们添加一些代码到 ContentView
,以便点击 + 按钮时可以显示 AddView
。
为了让 AddView
以新视图的方式呈现,我们需要对 ContentView
做出三点改变。首先,我们需要某个状态来跟踪是否显示AddView
,添加下面的属性:
@State private var showingAddExpense = false复制代码
接下来,我们需要告诉 SwiftUI 用这个布尔型作为显示 sheet 的条件 —— sheet 是一个弹出式的窗口,通过附加 sheet()
modifier 到视图层级的某个地方来实现。 如果你愿意,可以用在 List
上,不过 NavigationView
也可以。把下面的代码作为 modifier 添加到 ContentView
的某个视图:
.sheet(isPresented: $showingAddExpense) { // 在这里显示 AddView }复制代码
第三步是把东西放进 sheet ,通常是一个你想要展示的视图实例,像这样:
.sheet(isPresented: $showingAddExpense) { AddView() }复制代码
不过这里我们要用到一些东西。你看,我们在 ContentView 里已经有 expenses
属性,在 AddView
里我们打算写添加花销项的代码。我们一定不希望在AddView
里再写一个 Expense 实例,而是直接共享ContentView
里已经存在的实例。
所以我们要做的是在 AddView
中添加一个属性引用一个 Expenses
对象。它并不创建对象,只是声明它存在。把下面的属性添加到 AddView
:
@ObservedObject var expenses: Expenses复制代码
接下来我们把已经存在的 Expenses
对象从一个视图传递到另一个视图 —— 它们共享相同的对象,并且都会监视对象的变化。修改你的 sheet()
modifier ,像下面这样:
.sheet(isPresented: $showingAddExpense) { AddView(expenses: self.expenses) }复制代码
到这一步我们还没完成,有两个原因:我们的代码无法通过编译。即便通过编译,也无法工作。因为按钮没有触发 sheet 。
编译错误发生在新视图。当我们创建新的 SwiftUI 视图时, Xcode 也会添加一个预览 provider ,以便我们可以在编码的同时看到视图的设计。检查 AddView.swift 底部的代码,你会发现这里尝试构建一个有提供 expenses
属性的 AddView 实例。
我们可以传入一个默认的 Expense 消除编译错误,像这样:
struct AddView_Previews: PreviewProvider { static var previews: some View { AddView(expenses: Expenses()) } }复制代码
第二个问题是我们还没有显示添加新花销项的代码,因为之前点击 + 按钮添加的是测试用的花销项。只之前的代码替换为触发 showingAddExpense
布尔型:
Button(action: { self.showingAddExpense = true }) { Image(systemName: "plus") }复制代码
运行代码,sheet 如期工作 —— 从 ContentView
界面开始,点击 + 按钮调出 AddView
,在这里输入各项字段,然后向下扫关闭 sheet 。
译自 Making changes permanent with UserDefaults
到这里, app 的 UI 部分已经可以工作了:我们可以添加和删除花销项,还有一个 sheet 显示创建新花销项的 UI 。不过,app 还没完成,因为放进 AddView
的数据都被完全忽略。即使没被忽略,因为没有保存动作,下一次 app 启动时也会丢失。
我们将按顺序拆解这几个问题,先从处理 AddView
的数据开始。表单中的几个值已经有对应的属性,并且我们有从ContentView
传过来的 Expense 对象。
我们需要把这两样东西放在一起:需要用到一个按钮,当按钮点击时,基于这些属性值创建一个 ExpenseItem
,然后加到 Expense 对象的 expenses
。我们的 ExpenseItem
结构体的数量是一个整数,所以amount
字符串要做一次类型转换。
把下面的 navigationBarTitle()
modifier 添加到 AddView
:
.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) } })复制代码
尽管还有一些工作要做,建议可以先运行 app 看一下,因为现在基本上逻辑已经完整了 —— 你可以显示新建视图,输入细节,点击保存,滑动消除,看到列表中的新项目。这表明我们的数据同步完美工作:两个 SwiftUI 视图都从同一个花销项列表读取数据。
现在尝试重新启动 app ,你会立即遇到第二个问题:之前添加的任何数据都没有保存,也就是说,每次重启 app 都会回到一片空白。
很明显这是一种相当糟糕的用户体验,但幸运我们把Expense
作为一个独立的类设计,修复这个问题很简单。
我们将利用四项重要的技术,以一种清爽的方式保存和加载数据:
Codable
协议,能让我们打包任已经存在的花销项,以便存储UserDefaults
,我们保存和加载打包数据的地方Expenses
类的自定义构造器,以便基于从 UserDefaults
加载的已保存数据直接创建 Expense 实例。didSet
属性观察者,作为 Expense 的 items
属性的观察者,以便有任意一项增加或者减少时我们能保存变化。让我们先拆解数据写入。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") } } }复制代码
如果你紧跟进度,这个时候你应该发现代码又无法编译了。
问题在于 encoder.encode()
方法只能打包遵循 Codable
协议的对象。记住,遵循 Codable
意味着让编译器为我们生成可以处理打包和解包对象的代码。
添加 Codable
协议到 ExpenseItem
,像这样:
struct ExpenseItem: Identifiable, Codable { let id = UUID() let name: String let type: String let amount: Int }复制代码
Swift 的 UUID
,String
和 Int
类型都是 Codable 的,因此只要声明ExpenseItem
也遵循 Codable ,不需要额外工作就能实现了。
到此,保存数据的代码都写完了,还需要完成加载数据的部分。我们需要实现一个自定义构造器,它会做五件事:
UserDefaults
中读取数据。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? decoder.decode([ExpenseItem].self, from: items)
这行,它完成实际的工作,把 Data
对象解包成一个 ExpenseItem
对象数组。
当你第一次看到 [ExpenseItem].self
这种写法的时候一定狐疑 —— .self
是什么意思?是这样的,如果我们只用[ExpenseItem]
,Swift 会混淆我们的意图 —— 我们究竟是想复制一个类呢?还是打算引用一个静态属性或者方法?又或者是想创建一个类的实例。为了避免这种混乱 —— 表达我们想引入类型本身,所谓的 类型对象 ,我们在类型后面加上.self
。
加载和保存逻辑都到位了,现在你可以使用这个 app 了。不过它仍然还不是最终成品,我们还要做一些最后的打磨工作。
译自 a free Hacking with iOS: SwiftUI Edition tutorial
体验 app ,你应该会发现两个体验问题:
AddView
视图没有自动消失。结束这个项目之前,我们来完成最后的打磨
首先,通过存储一个对视图 presentation mode 的引用,然后当时机合适时在它上面调用dismiss()
可以关闭 AddView 。这个 presentation mode 是由视图的环境控制的,并且链接到 sheet 的 isPresented
参数 —— 这个布尔型在显示 AddView
之前被设置为 true,而当我们在 presentation mode 上调用 dismiss()
之后它会被置为 false 。
把这个属性添加到 AddView
:
@Environment(\.presentationMode) var presentationMode复制代码
你可能注意到我们没有指定类型 —— 那是因为基于@Environment
属性包装器,Swift 能够推断出变量的类型。
接下来,当我们要关闭视图时,我们需要调用 presentationMode.wrappedValue.dismiss()
,这会让 showingAddExpense
布尔型变回 false 并且关闭视图。在AddView
视图里我们有一个保存按钮,用于创建新的花销项并且保存到花销列表,所以我们可以直接把这行代码加到保存的逻辑后面:
self.presentationMode.wrappedValue.dismiss()复制代码
这样第一个体验就解决了。剩下一个在于我们只显示了每个花销项的名称。因为之前 ForEach
的代码是尝试性的:
ForEach(expenses.items) { item in Text(item.name) }复制代码
我们把上面的文本换成两层嵌套的 stack ,确保信息在屏幕上的视觉效果良好。内层的 stack 是VStack
,显示花销项的名称和类型,然后在外面是一个 HStack
。VStack
在左边,然后是 spacer,然后是费用。这种布局在 iOS 上很常见:标题和副标题在左边,更多信息在右边。
把 ForEach
里面的代码替换成下面这样:
ForEach(expenses.items) { item in HStack { VStack(alignment: .leading) { Text(item.name) .font(.headline) Text(item.type) } Spacer() Text("$\(item.amount)") } }复制代码
运行代码,完工!
我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~