译自 Sharing an observed object with a new view
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀
共享 Observed 对象给新视图
遵循 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
用 UserDefault 永久保存改动
到这里, 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 ,不需要额外工作就能实现了。
到此,保存数据的代码都写完了,还需要完成加载数据的部分。我们需要实现一个自定义构造器,它会做五件事:
- 以 “Items” 键从
UserDefaults中读取数据。 - 创建一个
JSONDecoder实例,它是跟JSONEncoder相反的部分,可以把 JSON 数据转换成 Swift 对象。 - 让 decoder 把我们从
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及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~