[SwiftUI 100 天] iExpense - part4

2020/3/28 23:02:32

本文主要是介绍[SwiftUI 100 天] iExpense - part4,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

译自 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 的 UUIDStringInt 类型都是 Codable 的,因此只要声明ExpenseItem也遵循 Codable ,不需要额外工作就能实现了。

到此,保存数据的代码都写完了,还需要完成加载数据的部分。我们需要实现一个自定义构造器,它会做五件事:

  1. 以 “Items” 键从UserDefaults中读取数据。
  2. 创建一个 JSONDecoder实例,它是跟JSONEncoder 相反的部分,可以把 JSON 数据转换成 Swift 对象。
  3. 让 decoder 把我们从 UserDefaults 读到的数据转换成一个ExpenseItem 对象的数组。
  4. 如果一切顺利,把结果数组赋给 items 然后退出函数。
  5. 否则,把 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 ,你应该会发现两个体验问题:

  1. 添加完一个新的花销项, AddView视图没有自动消失。
  2. 看不到花销项的细节信息。

结束这个项目之前,我们来完成最后的打磨

首先,通过存储一个对视图 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,显示花销项的名称和类型,然后在外面是一个 HStackVStack 在左边,然后是 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及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~





这篇关于[SwiftUI 100 天] iExpense - part4的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程