精通 SwiftUI - iOS 16 版

第 25 章
把所学应用出来!构建个人理财App

到目前为止,您应该已经对 SwiftUI 有了很好的了解,并且已经能使用这个新框架构建了一些简单的App。 在本章中,您应用所学的技巧来开发一只个人理财App,让使用者可以记录自己的收入和支出。

图 25.1. 个人理财App
图 25.1. 个人理财App

这个App构建起来并不太复杂,但你会学到很多关于 SwiftUI 的知识,并了解如何应用你所学的技术。 简而言之,以下是我们会建立的功能和将会讲解的内容:

  1. 如何构建表格并验证使用者输入
  2. 如何过滤记录和修改列表视图
  3. 如何使用底页(Bottom Sheet)显示记录详情
  4. 如何在 SwiftUI 中使用 MVVM(Model- View-ViewModel 的缩写)
  5. 如何利用 Core Data 储存和管理数据库的数据
  6. 如何使用 DatePicker 给使用者选择日期
  7. 如何处理键盘通知和调整表单位置

让我再次强调这一点。 这个App是你将所学应用出来的成果。 因此,我假设你已经阅读了第 1 章到第 24 章。你应该了解如何构建Bottom Sheet(第 18 章)、使用 Combine 验证表单(第 14 章和第 15 章)以及如何使用 Core Data(第 22 章)。 如果您还没有阅读这些章节,我建议您先阅读它们。 在本章中,我将集中讲解以前没有讨论过的技术。

下载完整项目

一般来说,我们是从头开始构建一个示例App。 这一次有点不同。 我已经写好了这个个人理财App。 您可以从 https://www.appcoda.com/resources/swiftui4/SwiftUIPFinance.zip 下载完整代码。 解压文件并在模拟器上试试运行App。 当App第一次启动时,它看起来与图 25.1 所示的有点不同,因为没有记录。 您可以点击 + 按钮添加新记录。 返回主视图后,您可在 Recent Transactions 部分看到新记录。 并且,App 会自动计算总余额。

此App 使用 Core Data 运行数据管理。 收入和支出记录存放在内置数据库中,因此即使在重新启动App后,您也会看到记录。

在本章的其余部分,我将详细解释内里的代码是如何运作的。 但我鼓励你试试自己先看一下代码,看看你能了解多少。

了解模型

正如您在项目导航器中看到的,App分为三个主要部分:模型(Model)、视图模型(ViewModel)和视图(View)。 让我们从模型层和Core Data模型开始。 打开 PaymentActivity.swift 看一下:

enum PaymentCategory: Int {
    case income = 0
    case expense = 1
}

public class PaymentActivity: NSManagedObject {

    @NSManaged public var paymentId: UUID
    @NSManaged public var date: Date
    @NSManaged public var name: String
    @NSManaged public var address: String?
    @NSManaged public var amount: Double
    @NSManaged public var memo: String?
    @NSManaged public var typeNum: Int32
}

extension PaymentActivity: Identifiable {
    var type: PaymentCategory {
        get {
            return PaymentCategory(rawValue: Int(typeNum)) ?? .expense
        }

        set {
            self.typeNum = Int32(newValue.rawValue)
        }
    }
}

PaymentActivity 类别代表支出或收入的付款记录。 在上面的代码,我们使用 Enum 来区分支付类型。 每笔付款都具有以下属性:

  • paymentId - 交易记录的 ID
  • date -交易日期
  • name - 交易名称
  • address - 你在那里消费/收入来自那里
  • amount - 交易金额
  • memo - 交易的附加说明
  • typeNum - 交易类型(收入/费用)

由于我们使用 Core Data 来永久记录交易数据,所以这个 PaymentActivity 类别继承 NSManagedObject。 稍后,您将在 Core Data 模型中看到,该类别被设置为客制化的托管物件(Managed Object)。 再说一次,如果您不了解 Core Data,请参阅第 22 章。

交易类型(即 typeNum)在数据库中是以整数存放的。 因此,我们需要在整数和Enum之间进行转换。 这是将Enum存放在数据库中的一种方法。

最后,我们采用了 Identifiable协议。 为什么我们需要使用它? 我们将使用List视图来展示所有交易活动。 这就是 PaymentActivity 类别采用该协议的原因。 如果你忘记了 Identifiable 协议是什么,你可以重读第 10 章。

使用 Core Data

现在,打开PFinanceStore查看托管数据模型(managed data model)。 在模型中,我们只有一个实体 PaymentActivity

图 25.2. PaymentActivity 实体
图 25.2. PaymentActivity 实体

正如我们之前讨论的,PaymentActivity类别就是用来配数据模型里这个PaymentActivity实体。 您可以单击数据模型检查器以显示设置。 如前所述,我更喜欢手动自订类别(而不是 codegen),所以 Class 里的 Codegen 选项是设为“Manual/None”。 这使我可以更灵活地自订类别。

接下来,让我们前往 Persistence.swift(在 Model 组内)看看这个数据模型是如何加载的。 在 PersistenceController 结构中,您应该看到以下代码:

struct PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    .
    .
    .

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "PFinanceStore")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
}

当App启动时,我们使用 NSPersistentContainer 加载 PFinanceStore.xcdatamodeld。 现在,切换到 PFinanceApp.swift 并看看代码:

struct PFinanceApp: App {

    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            DashboardView().environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

在 SwiftUI 中使用 Core Data 的技巧是将托管物件内容(managed object context)注入到环境中。 稍后,在 SwiftUI 视图中,我们可以轻松地从环境中读取 context 以进行下一步的操作。

实现 New Payment View

相信你已对模型层有一定了解,现在让我们看看如何实现每个视图。 New Payment 视图专为使用者建立新的交易(收入或支出)而设计。 打开 PaymentFormView.swift 看看, 您应该能够预览输入表单。

图 25.3. Payment Form 视图
图 25.3. Payment Form 视图

Form 的布局

让我先向您介绍表单的布局方式。 当开发 SwiftUI 介面时,时刻都要记着将一些常用的视图变得可以轻易重用。 由于大多数表单的文字栏都非常相似,我创建了一个通用的文本栏(即 FormTextField):

struct FormTextField: View {
    let name: String
    var placeHolder: String

    @Binding var value: String

    var body: some View {
        VStack(alignment: .leading) {
            Text(name.uppercased())
                .font(.system(.subheadline, design: .rounded))
                .fontWeight(.bold)
                .foregroundColor(.primary)

            TextField(placeHolder, text: $value)
                .font(.headline)
                .foregroundColor(.primary)
                .padding()
                .border(Color("Border"), width: 1.0)

        }
    }
}

您是否注意到表单标题下的两个验证错误? 由于这些验证信息的格式相似,我们就为此类信息建立了一个独立视图:

struct ValidationErrorText: View {

    var iconName = "info.circle"
    var iconColor = Color(red: 251/255, green: 128/255, blue: 128/255)

    var text = ""

    var body: some View {
        HStack {
            Image(systemName: iconName)
                .foregroundColor(iconColor)
            Text(text)
                .font(.system(.body, design: .rounded))
                .foregroundColor(.secondary)
            Spacer()
        }
    }
}

建立了这两个通用视图后,建立表格就变得非常简单。 我们使用 ScrollViewVStack 来排列表格栏。 只有在检测到错误时才会显示错误信息:

Group {
    if !paymentFormViewModel.isNameValid {
        ValidationErrorText(text: "Please enter the payment name")
    }

    if !paymentFormViewModel.isAmountValid {
        ValidationErrorText(text: "Please enter a valid amount")
    }

    if !paymentFormViewModel.isMemoValid {
        ValidationErrorText(text: "Your memo should not exceed 300 characters")
    }
}

type 字栏有点不同,因为它不属于文字栏。 使用者可以选择收入费用。 在本例中,我们建立了两个按钮:

VStack(alignment: .leading) {
    Text("TYPE")
        .font(.system(.subheadline, design: .rounded))
        .fontWeight(.bold)
        .foregroundColor(.primary)
        .padding(.vertical, 10)

    HStack(spacing: 0) {
        Button(action: {
            self.paymentFormViewModel.type = .income
        }) {
            Text("Income")
                .font(.headline)
                .foregroundColor(self.paymentFormViewModel.type == .income ? Color.white : Color.primary)
        }
        .frame(minWidth: 0.0, maxWidth: .infinity)
        .padding()
        .background(self.paymentFormViewModel.type == .income ? Color("IncomeCard") : Color.white)

        Button(action: {
            self.paymentFormViewModel.type = .expense
        }) {
            Text("Expense")
                .font(.headline)
                .foregroundColor(self.paymentFormViewModel.type == .expense ? Color.white : Color.primary)
        }
        .frame(minWidth: 0.0, maxWidth: .infinity)
        .padding()
        .background(self.paymentFormViewModel.type == .expense ? Color("ExpenseCard") : Color.white)
    }
    .border(Color("Border"), width: 1.0)
}

按钮的背景颜色因交易活动的类型而改变。

日期栏是使用 DatePicker 组件实现的。 要使用 DatePicker 非常容易, 您只需要提供标签、与日期值的绑定以及displayedComponents参数。

struct FormDateField: View {
    let name: String

    @Binding var value: Date

    var body: some View {
        VStack(alignment: .leading) {
            Text(name.uppercased())
                .font(.system(.subheadline, design: .rounded))
                .fontWeight(.bold)
                .foregroundColor(.primary)

            DatePicker("", selection: $value, displayedComponents: .date)
                .accentColor(.primary)
                .padding(10)
                .border(Color("Border"), width: 1.0)
                .labelsHidden()
        }
    }
}

在 iOS 14 (或以上版本),内置的 DatePicker改进了不少。新版本提供了更好的 UI 和更多样式。 如果您运行视图并点击日期字段,App会显示完整的日历视图供使用者选择日期。 UI 比旧版本的日期选择器要好得多。

图 25.4. 点击日期选项会显示完整的日历
图 25.4. 点击日期选项会显示完整的日历

memo 栏也不属文字栏,而是文字编辑器。 在 iOS 13 (或更旧版本),SwiftUI 没有提供多行文字输入的组件。 要支持多行文字编辑的话,您需要整合 UIKit 框架并将 UITextView 包装到 SwiftUI 组件中。 从 iOS 14 开始,Swift 引入了一个名为TextEditor的新组件,用于显示和编辑长文字。 在 PaymentFormView.swift 中,您应该找到以下结构:

struct FormTextEditor: View {
    let name: String
    var height: CGFloat = 80.0

    @Binding var value: String

    var body: some View {
        VStack(alignment: .leading) {
            Text(name.uppercased())
                .font(.system(.subheadline, design: .rounded))
                .fontWeight(.bold)
                .foregroundColor(.primary)

            TextEditor(text: $value)
                .frame(minHeight: height)
                .font(.headline)
                .foregroundColor(.primary)
                .padding()
                .border(Color("Border"), width: 1.0)
        }
    }
}

TextEditor 的用法与TextField 非常相似。 您只需将 String 变量绑定传递给TextEditor即可。 就像任何其他 SwiftUI 视图一样,您可以应用视图修饰器来设置其外观样式。

在表格的最后,就是 Save 按钮。 在默认的情况下,此按钮是不能用的。 使用者要填写所有必填表格栏时,这个 Save 按钮才可使用。 disabled 修饰器就是用于控制按钮的状态:

Button(action: {
    self.save()
    self.presentationMode.wrappedValue.dismiss()
}) {
    Text("Save")
        .opacity(paymentFormViewModel.isFormInputValid ? 1.0 : 0.5)
        .font(.headline)
        .foregroundColor(.white)
        .padding()
        .frame(minWidth: 0, maxWidth: .infinity)
        .background(Color("IncomeCard"))
        .cornerRadius(10)

}
.padding()
.disabled(!paymentFormViewModel.isFormInputValid)

当点击按钮时,App会调用save()方法将交易永久存放到数据库中。 然后,就调用 dismiss() 方法来关闭视图。 如果您不熟悉环境值 presentationMode,请阅读第 12 章。

表格验证

这就是布局表格 UI 的技巧,现在让我们来谈谈表格验证。 基本上,我是按照第 15 章中讨论的内容,使用 Combine 运行表格验证:

  1. 建立一个视图模型(view model)来代表交易活动表格(payment activity form)。
  2. 在视图模型中验证表格输入并使用 Combine 发布验证结果。

我们创建了一个视图模型类别(view model class)来储存表格栏的值。 你可以切换到PaymentFormViewModel.swift查看代码:

class PaymentFormViewModel: ObservableObject {

    // Input
    @Published var name = ""
    @Published var location = ""
    @Published var amount = ""
    @Published var type = PaymentCategory.expense
    @Published var date = Date.today
    @Published var memo = ""

    // Output
    @Published var isNameValid = false
    @Published var isAmountValid = true
    @Published var isMemoValid = true
    @Published var isFormInputValid = false

    private var cancellableSet: Set<AnyCancellable> = []

    init(paymentActivity: PaymentActivity?) {

        self.name = paymentActivity?.name ?? ""
        self.location = paymentActivity?.address ?? ""
        self.amount = "\(paymentActivity?.amount ?? 0.0)"
        self.memo = paymentActivity?.memo ?? ""
        self.type = paymentActivity?.type ?? .expense
        self.date = paymentActivity?.date ?? Date.today

        $name
            .receive(on: RunLoop.main)
            .map { name in
                return name.count > 0
            }
            .assign(to: \.isNameValid, on: self)
            .store(in: &cancellableSet)

        $amount
            .receive(on: RunLoop.main)
            .map { amount in
                guard let validAmount = Double(amount) else {
                    return false
                }
                return validAmount > 0
            }
            .assign(to: \.isAmountValid, on: self)
            .store(in: &cancellableSet)

        $memo
            .receive(on: RunLoop.main)
            .map { memo in
                return memo.count < 300
            }
            .assign(to: \.isMemoValid, on: self)
            .store(in: &cancellableSet)

        Publishers.CombineLatest3($isNameValid, $isAmountValid, $isMemoValid)
            .receive(on: RunLoop.main)
            .map { (isNameValid, isAmountValid, isMemoValid) in
                return isNameValid && isAmountValid && isMemoValid
            }
            .assign(to: \.isFormInputValid, on: self)
            .store(in: &cancellableSet)
    }

}

这个类别遵守ObservableObject。 所有属性都以@Published标注,因为我们想在数值发生变化时通知订阅者并相应地运行验证。

每当使用者输入表格栏时,此视图模型就会运行验证代码并修改结果,以及通知订阅者。

那么,谁是订阅者?

如果你回到 PaymentFormView.swift,你应该注意到我们已经用 @ObservedObject 标注了一个名为 paymentFormViewModel 的变量:

@ObservedObject private var paymentFormViewModel: PaymentFormViewModel

PaymentFormView 会侦测视图模型的变化。 当任何验证变量(例如 isNameValid)修改时,PaymentFormView 就会收到通知,视图相应地显示验证错误。

if !paymentFormViewModel.isNameValid {
    ValidationErrorText(text: "Please enter the payment name")
}

表格初始化

你有没有注意到init方法? 它接受一个PaymentActivity物件并初始化视图模型。

var payment: PaymentActivity?

init(payment: PaymentActivity? = nil) {
    self.payment = payment
    self.paymentFormViewModel = PaymentFormViewModel(paymentActivity: payment)
}

PaymentFormView 允许使用者建立新的交易并编辑现有交易记录。 这就是为什么 init 方法需要接受一个可选择性的支付物件。 如果物件是 nil,我们将显示一个空表格。 否则,我们用 PaymentActivity 物件填充表格栏的值。

预览表格

即时预览功能是 SwiftUI 其中一个最受欢迎的功能。 但是,如果要与 Core Data 整合,则需要一些额外的代码才能使预览成功运作。 这是预览的代码:

struct PaymentFormView_Previews: PreviewProvider {
    static var previews: some View {

        let context = PersistenceController.shared.container.viewContext

        let testTrans = PaymentActivity(context: context)
        testTrans.paymentId = UUID()
        testTrans.name = ""
        testTrans.amount = 0.0
        testTrans.date = .today
        testTrans.type = .expense

        return Group {
            PaymentFormView(payment: testTrans)
            PaymentFormView(payment: testTrans)
                .preferredColorScheme(.dark)
                .previewDisplayName("Payment Form View (Dark)")

            FormTextField(name: "NAME", placeHolder: "Enter your payment", value: .constant("")).previewLayout(.sizeThatFits)
                .previewDisplayName("Form Text Field")

            ValidationErrorText(text: "Please enter the payment name").previewLayout(.sizeThatFits)
                .previewDisplayName("Validation Error")

        }
    }
}

要使用PaymentFormView,您必须提供一个PaymentActivity物件。 由于PaymentActivity是一个托管物件(managed object),我们需要从PersistenceController中读取context来创建一个。 一旦我们创建了 PaymentActivity 物件,就可以用它来预览 PaymentFormView

实现交易活动详细视图

现在让我们讨论下一个视图并看看交易活动详细视图是如何实现的。 当使用者在 Recent Transactions 中选择一项交易活动时,此视图就会弹出来。 它显示交易的详细信息,例如数额和消费地点。 您可以打开 PaymentDetailView.swift 来查看 UI 的外观, 这样您会更了解详细视图。

图 25.5. 交易活动详细视图
图 25.5. 交易活动详细视图

使用者介面

详细视图非常简单。 相信你都知道如何布局组件,我就不逐行解释代码了。 要留意是以下的代码:

let payment: PaymentActivity

private let viewModel: PaymentDetailViewModel

init(payment: PaymentActivity) {

    self.payment = payment
    self.viewModel = PaymentDetailViewModel(payment: payment)
}

由于我们需要运行一些初始化来创建视图模型,我们加了一个客制化的 init 方法。

视图模型(View Model)

我们可以把视图分成视图及其视图模型等两个组件,而不是将所有的东西放在一个视图中。视图本身是负责 UI 布局,而视图模型存放要在视图中显示的状态与数据,并且视图模型还处理数据验证与转换。对于有经验的开发者而言,你知道我们正应用众所周知的“MVVM”(Model- View-ViewModel 的缩写)设计模式。

- 摘自第 15 章

为了将实际的视图数据与视图 UI 分开,我们建立了一个名为PaymentDetailViewModel的视图模型:

private let viewModel: PaymentDetailViewModel

为什么我们需要创建一个额外的视图模型来存放视图的数据? 看看标题 Payment Details 旁边的图标。 这是一个动态图标,会随着支付/交易类型而变化。 另外,你注意到金额的格式吗? App的一项要求是金额仅可显示两个小数位。当然, 我们可以在视图中处理这个格式问题,但是如果您不断在视图中加入这些逻辑处理,视图将变得过于复杂变得越来越难维护。

计算机程序设计有一个原则叫做“单一职责”原则(single responsibility principle)。它指出程序中的每个类别或模块都应该只负责一个功能。 SRP (single responsibility principle的缩写)是编写好代码的关键之一,让您的代码更易于维护和阅读。

这就是我们将视图分为两个组件的原因:

  1. PaymentDetailView 只负责UI布局。
  2. PaymentDetailViewModel 则负责将视图的数据转换为预定的显示格式。

打开 PaymentDetailViewModel 看看:

struct PaymentDetailViewModel {

    var payment: PaymentActivity

    var name: String {
        return payment.name
    }

    var date: String {
        return payment.date.string()
    }

    var address: String {
        return payment.address ?? ""
    }

    var typeIcon: String {

        let icon: String

        switch payment.type {
        case .income: icon = "arrowtriangle.up.circle.fill"
        case .expense: icon = "arrowtriangle.down.circle.fill"
        }

        return icon
    }

    var image: String = "payment-detail"

    var amount: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.minimumFractionDigits = 2

        let formattedValue = formatter.string(from: NSNumber(value: payment.amount)) ?? ""

        let formattedAmount = ((payment.type == .income) ? "+" : "-") + "$" + formattedValue

        return formattedAmount
    }

    var memo: String {
        return payment.memo ?? ""
    }

    init(payment: PaymentActivity) {
        self.payment = payment
    }

}

如您所见,我们在此视图模型(view model)中加入所有转换逻辑。 我们可以把这些逻辑放回视图(view)中吗? 当然可以。 但是,我相信将视图分成两部分(view 和 view model)会使代码更清楚。

仪表板视图

现在是时候和你讲解仪表板视图了。 在个人理财App的所有视图中,这个视图是最复杂的。

图 25.6. 仪表板视图
图 25.6. 仪表板视图

菜单栏(Menu Bar)

打开 Dashboard.swift,让我们从菜单栏开始:

struct MenuBar<Content>: View where Content: View {
    @State private var showPaymentForm = false

    let modalContent: () -> Content

    var body: some View {
        ZStack(alignment: .trailing) {
            HStack(alignment: .center) {
                Spacer()

                VStack(alignment: .center) {
                    Text(Date.today.string(with: "EEEE, MMM d, yyyy"))
                        .font(.caption)
                        .foregroundColor(.gray)
                    Text("Personal Finance")
                        .font(.title)
                        .fontWeight(.black)
                }

                Spacer()
            }

            Button(action: {
                self.showPaymentForm = true
            }) {
                Image(systemName: "plus.circle")
                    .font(.title)
                    .foregroundColor(.primary)
            }

            .sheet(isPresented: self.$showPaymentForm, onDismiss: {
                self.showPaymentForm = false
            }) {
                self.modalContent()
            }
        }

    }
}

菜单栏的布局很简单。 它显示App的标题、今天的日期和+按钮。 此菜单栏视图能够接受任何强制回应视图(即modalContent)。 点击 + 按钮时,将显示模态视图。 如果你不知道如何在 SwiftUI 中创建通用视图,可以参考第 17 章。

收入、支出和总余额

接下来,我们利用三个卡片视图来显示总余额、收入和支出。 这是收入卡视图的代码:

struct IncomeCard: View {
    var income = 0.0

    var body: some View {

        ZStack {
            Rectangle()
                .foregroundColor(Color("IncomeCard"))
                .cornerRadius(15.0)

            VStack {
                Text("Income")
                    .font(.system(.title, design: .rounded))
                    .fontWeight(.black)
                    .foregroundColor(.white)
                Text(NumberFormatter.currency(from: income))
                    .font(.system(.title, design: .rounded))
                    .fontWeight(.bold)
                    .foregroundColor(.white)
                    .minimumScaleFactor(0.1)
            }
        }
        .frame(height: 150)

    }
}

我们只需使用 ZStack 将文字覆盖在彩色矩形上。 利用类似的技巧,我们就可布局 TotalBalanceCardExpenseCard。 那么,我们如何计算收入、支出和总余额呢? 我们在 DashboardView 的开头声明了三个计算属性:

private var totalIncome: Double {
    let total = paymentActivities
        .filter {
            $0.type == .income
        }.reduce(0) {
            $0 + $1.amount
        }

    return total
}

private var totalExpense: Double {
    let total = paymentActivities
        .filter {
            $0.type == .expense
        }.reduce(0) {
            $0 + $1.amount
        }

    return total
}

private var totalBalance: Double {
    return totalIncome - totalExpense
}

paymentActivities 变量存放了所有交易活动。要计算总收入的话,我们首先使用 filter 函数过滤那些交易为 .income ,然后使用 reduce 函数计算总收入。 只要使用相同的技巧就可计算出总支出。 Swift 的高阶函数非常好用。 如果您不知道如何使用 filter 和 reduce,您可以读一读这篇教学文(https://www.appcoda.com/higher-order-functions-swift/)。

最近的交易

UI 的最后一部分是最近交易的列表。 由于所有行的布局都一样(支付类型的图标除外),我们为交易行创建一个通用视图,如下所示:

struct TransactionCellView: View {

    @ObservedObject var transaction: PaymentActivity

    var body: some View {

        HStack(spacing: 20) {

            if transaction.isFault {
                EmptyView()

            }  else {

                Image(systemName: transaction.type == .income ? "arrowtriangle.up.circle.fill" : "arrowtriangle.down.circle.fill")
                    .font(.title)
                    .foregroundColor(Color(transaction.type == .income ? "IncomeCard" : "ExpenseCard"))

                VStack(alignment: .leading) {
                    Text(transaction.name)
                        .font(.system(.body, design: .rounded))
                    Text(transaction.date.string())
                        .font(.system(.caption, design: .rounded))
                        .foregroundColor(.gray)
                }

                Spacer()

                Text((transaction.type == .income ? "+" : "-") + NumberFormatter.currency(from: transaction.amount))
                    .font(.system(.headline, design: .rounded))
            }
        }
        .padding(.vertical, 5)

    }
}

这个cell视图接受一个PaymentActivity物件以显示它的内容。 为了确保托管物件(即交易)有效,我们会读取isFault属性先检查一下。

为了显示所有交易,我们使用ForEach将每一个交易活动创建一个TransactionCellView

ForEach(paymentDataForView) { transaction in
    TransactionCellView(transaction: transaction)
        .onTapGesture {
            self.showPaymentDetails = true
            self.selectedPaymentActivity = transaction
        }
        .contextMenu {
            Button(action: {
                // Edit payment details
                self.editPaymentDetails = true
                self.selectedPaymentActivity = transaction

            }) {
                HStack {
                    Text("Edit")
                    Image(systemName: "pencil")
                }
            }

            Button(action: {
                // Delete the selected payment
                self.delete(payment: transaction)
            }) {
                HStack {
                    Text("Delete")
                    Image(systemName: "trash")
                }
            }
        }
}
.sheet(isPresented: self.$editPaymentDetails) {
    PaymentFormView(payment: self.selectedPaymentActivity).environment(\.managedObjectContext, self.context)
}

当使用者按住其中一行时,它会显示一个包含删除和编辑选项的内容菜单(Context Menu)。 选择编辑选项时,App将使用所选的交易创建PaymentFormView。 对于删除动作,我们通过 Core Data 从数据库中完全删除交易记录。

图 25.7. 内容菜单
图 25.7. 内容菜单

不知你没有注意到 paymentDataForView 变量? 列表视图并没有使用 paymentActivities,而是显示存放在 paymentDataForView 中的项目。 为什么是这样?

Recent Transactions 部分,App为使用者提供了三个选项来过滤交易活动,包括全部(All)、收入(Income)和支出(Expense)。 例如,如果选择了 expense 选项,就仅显示与与支出相关的交易。

private var paymentDataForView: [PaymentActivity] {

    switch listType {
    case .all:
        return paymentActivities
            .sorted(by: { $0.date.compare($1.date) == .orderedDescending })
    case .income:
        return paymentActivities
            .filter { $0.type == .income }
            .sorted(by: { $0.date.compare($1.date) == .orderedDescending })
    case .expense:
        return paymentActivities
            .filter { $0.type == .expense }
            .sorted(by: { $0.date.compare($1.date) == .orderedDescending })
    }
}

paymentDataForView 是另一个计算属性,它传回一个交易活动集合。 在代码中,我们使用filter函数来过滤交易活动,并使用sort函数将交易活动排序。

底页(The Bottom Sheet)

交易详细信息视图是以BottomSheet形式显示。 当使用者点击交易记录时,bottom sheet 会从屏幕底部弹出来并显示支付详情。 另外,bottom sheet 是可以扩展至全屏幕的,使用者可以向上拖动详细视图就可以展开它。 相反地,使用者可以向下拖动视图将其关闭。

.sheet(isPresented: $showPaymentDetails) {
    PaymentDetailView(payment: selectedPaymentActivity!)
        .presentationDetents([.medium, .large])       
}

我们在第 18 章中使用了.presentationDetents 修饰器来实现了一个类似的Bottom Sheet。因此,我们重用了该章中大部分的代码。 如果你想了解更多关于 BottomSheet 的构建原理,你可以重读那一章。 在这里,我们将工作表定义为可扩展的底部工作表。 它以 medium 大小开始。 但是使用者可以向上拖动工作表以将其扩展为large尺寸。

使用Core Data处理交易活动

如前所述,所有交易活动都存放在数据库中,并通过 Core Data 进行管理。 在代码中,我们使用 @FetchRequest 属性包装器来读取交易活动,如下所示:

@FetchRequest(
    entity: PaymentActivity.entity(),
    sortDescriptors: [ NSSortDescriptor(keyPath: \PaymentActivity.date, ascending: false) ])
var paymentActivities: FetchedResults<PaymentActivity>

这个属性包装器让运行读取请求变得非常容易。 我们只需指定实体,即PaymentActivity,以及描述数据应如何排序。 然后,Core Data 框架会使用环境的托管物件内容(managed object context)来读取数据。 最重要的是,SwiftUI 会自动修改列表视图和相关视图。

从数据库中删除交易记录也是非常简单。 我们调用contextdelete 函数并指定要删除的交易就可以了:

private func delete(payment: PaymentActivity) {
    self.context.delete(payment)

    do {
        try self.context.save()
    } catch {
        print("Failed to save the context: \(error.localizedDescription)")
    }
}

要了解如何添加新交易或修改现有交易记录,就要打开PaymentFormView。 如果你再次查看 PaymentFormView.swift ,你会发现 save() 函数:

private func save() {
    let newPayment = payment ?? PaymentActivity(context: context)

    newPayment.paymentId = UUID()
    newPayment.name = paymentFormViewModel.name
    newPayment.type = paymentFormViewModel.type
    newPayment.date = paymentFormViewModel.date
    newPayment.amount = Double(paymentFormViewModel.amount)!
    newPayment.address = paymentFormViewModel.location
    newPayment.memo = paymentFormViewModel.memo

    do {
        try context.save()
    } catch {
        print("Failed to save the record...")
        print(error.localizedDescription)
    }
}

代码的第一行检查一下payment 是不是 nil。 如果是 nil 的话,我们就建立一个全新的PaymentActivity物件。 然后我们将newPayment填入所需值,最后就是调用 contextsave() 来添加/修改数据库中的记录。

应用扩展(Extensions)

为方便起见,我们构建了两个扩展来格式化日期和数字。 在项目导航器中,您应该在 Extension 文件夹下找到两个文件。 先看一下Date+Ext.swift

extension Date {
    static var today: Date {
        return Date()
    }

    static var yesterday: Date {
        return Calendar.current.date(byAdding: .day, value: -1, to: Date())!
    }

    static var tomorrow: Date {
        return Calendar.current.date(byAdding: .day, value: 1, to: Date())!
    }

    var month: Int {
        return Calendar.current.component(.month, from: self)
    }

    static func fromString(string: String, with format: String = "yyyy-MM-dd") -> Date? {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = format
        return dateFormatter.date(from: string)
    }

    func string(with format: String = "dd MMM yyyy") -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = format
        return dateFormatter.string(from: self)
    }
}

在上面的代码,我们建立了 Date 的扩展以提供额外的功能,包括:

  • 读取今天的日期
  • 读取明天的日期
  • 读取昨天的日期
  • 读取日期的月份
  • 将当前日期转换为字串,反之亦然

为了将金额格式化,我们写了个 NumberFormatter 扩展以提供额外的功能:

extension NumberFormatter {
    static func currency(from value: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal

        let formattedValue = formatter.string(from: NSNumber(value: value)) ?? ""

        return "$" + formattedValue
    }
}

此函数接收一个值,并将它转换为字符串,然后再在最前面加上美元符号 ($)。

处理软体键盘

PaymentFormView.swift ,我们添加了以下修饰器:

.keyboardAdaptive()

这是一个自订的视图修饰器,为处理软体键盘而开发。 在 iOS 14以上版本,是不再需要此修饰器,但我特意添加了它,因为如果你的App要支持 iOS 13,就可能需要它。

在 iOS 13 上,软体键盘在未应用修饰器的情况下会遮掩部分表格。 举例,如果您尝试点击备忘录栏,它会完全隐藏在键盘后面。 相反,如果将修饰器附加到滚动视图,当键盘出现时,表格会自动向上移动。 在 iOS 14 上,系统本身会自动处理软体键盘的位置,防止它遮掩文字栏。

图 25.8. 没使用 keyboardAdaptive (left), 使用了 keyboardAdaptive
图 25.8. 没使用 keyboardAdaptive (left), 使用了 keyboardAdaptive

现在让我们看看代码(KeyboardAdaptive.swift),以了解如何处理键盘事件:

struct KeyboardAdaptive: ViewModifier {

    @State var currentHeight: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .padding(.bottom, currentHeight)
            .onAppear(perform: handleKeyboardEvents)
    }

    private func handleKeyboardEvents() {

        NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification
        ).compactMap { (notification) in
            notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect
        }.map { rect in
            rect.height
        }.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))

        NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification
        ).compactMap { _ in
            CGFloat.zero
        }.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))

    }
}

extension View {
    func keyboardAdaptive() -> some View {
        ModifiedContent(content: self, modifier: KeyboardAdaptive())
    }
}

每当键盘出现(或消失)时,iOS 都会向App发送通知:

  • keyboardWillShowNotification - 当键盘即将出现时就会发送此通知
  • keyboardWillHideNotification - 当键盘即将消失时发送此通知

那么,我们如何利用这些通知来向上滚动表格呢? 当App收到 keyboardWillShowNotification 时,它会向表格添加 padding 使其向上移动。 相反,当收到 keyboardWillHideNotification 时,我们就将padding设定为零。

在上面的代码,我们有一个状态变量来存放键盘的高度。 通过使用 Combine 框架,我们有一个发布者,当侦测到 keyboardWillShowNotification 就会立即发布键盘的当前高度。 此外,我们还有另一个发布者聆听 keyboardWillHideNotification 并发出0值。 对于这两个发布者,我们使用内置的 assign 将这些发布者发出的值传给 currentHeight 变量。

这就是移动键盘的方式和原理。 但是为什么我们需要有 View 扩展呢?

该代码无需扩展也可运作。 您可以将代码写成这样来处理键盘事件:

.modifier(KeyboardAdaptive())

为了使代码更简洁,我们创建了扩展并添加了 keyboardAdaptive() 函数。 之后,就可以直接将修饰哭附加到任何视图,如下所示:

.keyboardAdaptive()

由于此视图修饰器仅适用于 iOS 13,因此我们在 keyboardAdaptive() 函数中使用 #available 来检查 OS 版本:

extension View {
    func keyboardAdaptive() -> some View {
        if #available(iOS 14.0, *) {
            return AnyView(self)
        } else {
            return AnyView(ModifiedContent(content: self, modifier: KeyboardAdaptive()))
        }
    }
}

总结

这就是我们从零开始构建个人理财App的方式。 此章使用的大多数技术对您来说都不是新的,我们只是将所学应用出来。

SwiftUI 是一个非常强大且好用的框架,它允许您使用比 UIKit 更少的代码构建相同的App。 如果您对 UIKit 有一定的开发经验,您就会知道如使用UIKit创建个人理财App,将会花费您更多的时间和需要写更多代码。 我真的希望你喜欢学习 SwiftUI 并使用这个新框架构建 UI。