到目前為止,您應該已經對 SwiftUI 有了很好的了解,並且已經能使用這個新框架構建了一些簡單的App。 在本章中,您應用所學的技巧來開發一隻個人理財App,讓使用者可以記錄自己的收入和支出。
這個App構建起來並不太複雜,但你會學到很多關於 SwiftUI 的知識,並了解如何應用你所學的技術。 簡而言之,以下是我們會建立的功能和將會講解的內容:
讓我再次強調這一點。 這個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 來區分支付類型。 每筆付款都具有以下屬性:
由於我們使用 SwiftData 來永久記錄交易數據,因此這個 PaymentActivity
類別用 @Model
巨集進行註解。 使用此關鍵字,SwiftData 會自動啟用資料類別的持久性。 @Attribute
註解加入了 paymentId
屬性的唯一性約束。再說一次,如果您不了解 SwiftData,請參閱第 22 章。
交易類型(即 typeNum
)在數據庫中是以整數存放的。 因此,我們需要在整數和Enum之間進行轉換。 這是將Enum存放在數據庫中的一種方法。
當 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 視圖專為使用者建立新的交易(收入或支出)而設計。 打開 PaymentFormView.swift
看看, 您應該能夠預覽輸入表單。
讓我先向您介紹表單的佈局方式。 當開發 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()
}
}
}
建立了這兩個通用視圖後,建立表格就變得非常簡單。 我們使用 ScrollView
和 VStack
來排列表格欄。 只有在檢測到錯誤時才會顯示錯誤訊息:
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 比舊版本的日期選擇器要好得多。
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 執行表格驗證:
我們創建了一個視圖模型類別(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 的外觀, 這樣您會更了解詳細視圖。
詳細視圖非常簡單。 相信你都知道如何佈局組件,我就不逐行解釋程式碼了。 要留意是以下的程式碼:
let payment: PaymentActivity
private let viewModel: PaymentDetailViewModel
init(payment: PaymentActivity) {
self.payment = payment
self.viewModel = PaymentDetailViewModel(payment: payment)
}
由於我們需要執行一些初始化來創建視圖模型,我們加了一個客製化的 init
方法。
我們可以把視圖分成視圖及其視圖模型等兩個元件,而不是將所有的東西放在一個視圖中。視圖本身是負責 UI 佈局,而視圖模型存放要在視圖中顯示的狀態與資料,並且視圖模型還處理資料驗證與轉換。對於有經驗的開發者而言,你知道我們正應用眾所周知的「MVVM」(Model- View-ViewModel 的縮寫)設計模式。
- 摘自第 15 章
為了將實際的視圖數據與視圖 UI 分開,我們建立了一個名為PaymentDetailViewModel
的視圖模型:
private let viewModel: PaymentDetailViewModel
為什麼我們需要創建一個額外的視圖模型來存放視圖的數據? 看看標題 Payment Details 旁邊的圖標。 這是一個動態圖標,會隨著支付/交易類型而變化。 另外,你注意到金額的格式嗎? App的一項要求是金額僅可顯示兩個小數位。當然, 我們可以在視圖中處理這個格式問題,但是如果您不斷在視圖中加入這些邏輯處理,視圖將變得過於複雜變得越來越難維護。
計算機程式設計有一個原則叫做「單一職責」原則(single responsibility principle)。它指出程式中的每個類別或模塊都應該只負責一個功能。 SRP (single responsibility principle的縮寫)是編寫好程式碼的關鍵之一,讓您的程式碼更易於維護和閱讀。
這就是我們將視圖分為兩個組件的原因:
PaymentDetailView
只負責UI佈局。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的所有視圖中,這個視圖是最複雜的。
打開 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
將文字覆蓋在彩色矩形上。 利用類似的技巧,我們就可佈局 TotalBalanceCard
和 ExpenseCard
。 那麼,我們如何計算收入、支出和總餘額呢? 我們在 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 從數據庫中完全刪除交易記錄。
不知你沒有注意到 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
函數將交易活動排序。
交易詳細信息視圖是以BottomSheet
形式顯示。 當使用者點擊交易記錄時,bottom sheet 會從螢幕底部彈出來並顯示支付詳情。 另外,bottom sheet 是可以擴展至全螢幕的,使用者可以向上拖動詳細視圖就可以展開它。 相反地,使用者可以向下拖動視圖將其關閉。
.sheet(isPresented: $showPaymentDetails) {
PaymentDetailView(payment: selectedPaymentActivity!)
.presentationDetents([.medium, .large])
}
我們在第 18 章中使用了.presentationDetents
修飾器來實現了一個類似的Bottom Sheet。因此,我們重用了該章中大部分的程式碼。 如果你想了解更多關於 BottomSheet
的構建原理,你可以重讀那一章。 在這裡,我們將工作表定義為可擴展的底部工作表。 它以 medium 大小開始。 但是使用者可以向上拖動工作表以將其擴展為large尺寸。
如前所述,所有交易活動都存放在數據庫中,並透過 SwiftData 進行管理。 在程式碼中,我們使用 @Query
屬性包裝器來讀取交易活動,如下所示:
@Query var paymentActivities: [PaymentActivity]
這個屬性包裝器讓執行讀取請求變得非常容易。 最重要的是,SwiftUI 會自動更新列表視圖和相關視圖。
從數據庫中刪除交易記錄也是非常簡單。 我們呼叫 modelContext
的 delete
函數並指定要刪除的交易就可以了:
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)
}
以上程式碼就是呼叫 modelContext
的 insert
來添加/更新數據庫中的記錄。
為方便起見,我們構建了兩個擴展來格式化日期和數字。 在項目導航器中,您應該在 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 上,系統本身會自動處理軟體鍵盤的位置,防止它遮掩文字欄。
現在讓我們看看程式碼(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發送通知:
那麼,我們如何利用這些通知來向上滾動表格呢? 當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。