精通 SwiftUI - iOS 17 版

第 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. 如何利用 SwiftData 儲存和管理數據庫的數據
  6. 如何使用 DatePicker 給使用者選擇日期
  7. 如何處理鍵盤通知和調整表單位置

讓我再次強調這一點。 這個App是你將所學應用出來的成果。 因此,我假設你已經閱讀了第 1 章到第 24 章。你應該了解如何構建Bottom Sheet(第 18 章)、使用 Combine 驗證表單(第 14 章和第 15 章)以及如何使用 SwiftData(第 22 章)。 如果您還沒有閱讀這些章節,我建議您先閱讀它們。 在本章中,我將集中講解以前沒有討論過的技術。

下載完整專案

一般來說,我們是從頭開始構建一個範例App。 這一次有點不同。 我已經寫好了這個個人理財App。 您可以從 https://www.appcoda.com/resources/swiftui5/SwiftUIPFinance.zip 下載完整程式碼。 解壓檔案並在模擬器上試試運行App。 當App第一次啟動時,它看起來與圖 25.1 所示的有點不同,因為沒有記錄。 您可以點擊 + 按鈕添加新記錄。 返回主視圖後,您可在 Recent Transactions 部分看到新記錄。 並且,App 會自動計算總餘額。

此App 使用 SwiftData 執行數據管理。 收入和支出記錄存放在內置數據庫中,因此即使在重新啟動App後,您也會看到記錄。

在本章的其餘部分,我將詳細解釋內裡的程式碼是如何運作的。 但我鼓勵你試試自己先看一下程式碼,看看你能了解多少。

了解模型

正如您在項目導航器中看到的,App分為三個主要部分:模型(Model)、視圖模型(ViewModel)和視圖(View)。 讓我們從模型層和Core Data模型開始。 打開 PaymentActivity.swift 看一下:

import SwiftData

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

@Model class PaymentActivity  {
    @Attribute(.unique) var paymentId: UUID
    var date: Date
    var name: String
    var address: String?
    var amount: Double
    var memo: String?
    @Transient var type: PaymentCategory {
        get {
            PaymentCategory(rawValue: Int(typeNum)) ?? .expense
        }

        set {
            self.typeNum = Int(newValue.rawValue)
        }
    }
    @Attribute(originalName: "type") var typeNum: PaymentCategory.RawValue

    init(paymentId: UUID = UUID(), date: Date, name: String, address: String? = nil, amount: Double, memo: String? = nil, type: PaymentCategory) {
        self.paymentId = paymentId
        self.date = date
        self.name = name
        self.address = address
        self.amount = amount
        self.memo = memo
        self.type = type
    }
}

PaymentActivity 類別代表支出或收入的付款記錄。 在上面的程式碼,我們使用 Enum 來區分支付類型。 每筆付款都具有以下屬性:

  • paymentId - 交易記錄的 ID
  • date -交易日期
  • name - 交易名稱
  • address - 你在那裡消費/收入來自那裡
  • amount - 交易金額
  • memo - 交易的附加說明
  • typeNum - 交易類型(收入/費用)

由於我們使用 SwiftData 來永久記錄交易數據,因此這個 PaymentActivity 類別用 @Model 巨集進行註解。 使用此關鍵字,SwiftData 會自動啟用資料類別的持久性。 @Attribute 註解加入了 paymentId 屬性的唯一性約束。再說一次,如果您不了解 SwiftData,請參閱第 22 章。

交易類型(即 typeNum)在數據庫中是以整數存放的。 因此,我們需要在整數和Enum之間進行轉換。 這是將Enum存放在數據庫中的一種方法。

模型容器(The Model Container)

當 App 啟動時,我們使用 .modelContainer 修飾器設定模型容器來儲存支付活動。 現在,切換到PFinanceApp.swift 並檢查程式碼:

import SwiftUI
import SwiftData

@main
struct PFinanceApp: App {
    var body: some Scene {
        WindowGroup {
            DashboardView()
        }
        .modelContainer(for: PaymentActivity.self)
    }
}

為了使用 SwiftData 驅動資料管理操作,我們需要準備用作持久後端的模型容器。 稍後,在 SwiftUI 視圖中,我們可以輕鬆地從環境中檢索 model context 以進行進一步的操作。

實作 New Payment View

相信你已對模型層有一定了解,現在讓我們看看如何實作每個視圖。 New Payment 視圖專為使用者建立新的交易(收入或支出)而設計。 打開 PaymentFormView.swift 看看, 您應該能夠預覽輸入表單。

圖 25.2. Payment Form 視圖
圖 25.2. 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)
                .foregroundStyle(.primary)

            TextField(placeHolder, text: $value)
                .font(.headline)
                .foregroundStyle(.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)
                .foregroundStyle(iconColor)
            Text(text)
                .font(.system(.body, design: .rounded))
                .foregroundStyle(.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)
        .foregroundStyle(.primary)
        .padding(.vertical, 10)

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

        Button(action: {
            self.paymentFormViewModel.type = .expense
        }) {
            Text("Expense")
                .font(.headline)
                .foregroundStyle(self.paymentFormViewModel.type == .expense ? Color.white : Color.primary)
        }
        .frame(minWidth: 0.0, maxWidth: .infinity)
        .padding()
        .background(self.paymentFormViewModel.type == .expense ? Color("ExpenseCard") : Color(.systemBackground))
    }
    .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)
                .foregroundStyle(.primary)

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

在 iOS 14 (或以上版本),內置的 DatePicker改進了不少。新版本提供了更好的 UI 和更多樣式。 如果您運行視圖並點擊日期字段,App會顯示完整的日曆視圖供使用者選擇日期。 UI 比舊版本的日期選擇器要好得多。

圖 25.3. 點擊日期選項會顯示完整的日曆
圖 25.3. 點擊日期選項會顯示完整的日曆

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)
                .foregroundStyle(.primary)

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

TextEditor 的用法與TextField 非常相似。 您只需將 String 變數綁定傳遞給TextEditor即可。 就像任何其他 SwiftUI 視圖一樣,您可以應用視圖修飾器來設置其外觀樣式。

在表格的最後,就是 Save 按鈕。 在預設的情況下,此按鈕是不能用的。 使用者要填寫所有必填表格欄時,這個 Save 按鈕才可使用。 disabled 修飾器就是用於控制按鈕的狀態:

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

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

當點擊按鈕時,App會呼叫save()方法將交易永久存放到數據庫中。 然後,就呼叫 dismiss() 方法來關閉視圖。

表格驗證

這就是佈局表格 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 物件填充表格欄的值。

實作交易活動詳細視圖

現在讓我們討論下一個視圖並看看交易活動詳細視圖是如何實現的。 當使用者在 Recent Transactions 中選擇一項交易活動時,此視圖就會彈出來。 它顯示交易的詳細信息,例如數額和消費地點。 您可以打開 PaymentDetailView.swift 來查看 UI 的外觀, 這樣您會更了解詳細視圖。

圖 25.4. 交易活動詳細視圖
圖 25.4. 交易活動詳細視圖

使用者介面

詳細視圖非常簡單。 相信你都知道如何佈局組件,我就不逐行解釋程式碼了。 要留意是以下的程式碼:

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.5. 儀表板視圖
圖 25.5. 儀表板視圖

菜單欄(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.tw/higher-order-function/)。

最近的交易

UI 的最後一部分是最近交易的列表。 由於所有行的佈局都一樣(支付類型的圖標除外),我們為交易行創建一個通用視圖,如下所示:

struct TransactionCellView: View {

    var transaction: PaymentActivity

    var body: some View {

        HStack(spacing: 20) {

            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)
}

當使用者按住其中一行時,它會顯示一個包含刪除和編輯選項的內容選單(Context Menu)。 選擇編輯選項時,App將使用所選的交易創建PaymentFormView。 對於刪除動作,我們透過 Core Data 從數據庫中完全刪除交易記錄。

圖 25.6. 內容選單
圖 25.6. 內容選單

不知你沒有注意到 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尺寸。

使用 SwiftData 處理交易活動

如前所述,所有交易活動都存放在數據庫中,並透過 SwiftData 進行管理。 在程式碼中,我們使用 @Query 屬性包裝器來讀取交易活動,如下所示:

@Query var paymentActivities: [PaymentActivity]

這個屬性包裝器讓執行讀取請求變得非常容易。 最重要的是,SwiftUI 會自動更新列表視圖和相關視圖。

從數據庫中刪除交易記錄也是非常簡單。 我們呼叫 modelContextdelete 函數並指定要刪除的交易就可以了:

private func delete(payment: PaymentActivity) { 
    self.modelContext.delete(payment)
}

要了解如何添加新交易或更新現有交易記錄,就要打開PaymentFormView。 如果你再次查看 PaymentFormView.swift ,你會發現 save() 函數:

private func save() {
    let newPayment = PaymentActivity(
                        date: paymentFormViewModel.date,
                        name: paymentFormViewModel.name,
                        address: paymentFormViewModel.location,
                        amount: Double(paymentFormViewModel.amount)!,
                        memo: paymentFormViewModel.memo,
                        type: paymentFormViewModel.type)

    modelContext.insert(newPayment)
}

以上程式碼就是呼叫 modelContextinsert 來添加/更新數據庫中的記錄。

應用擴展(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.7. 沒使用 keyboardAdaptive (left), 使用了 keyboardAdaptive
圖 25.7. 沒使用 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。