現在你應該對於 Combine 有了基本的了解,我們將繼續探索 Combine 如何使 SwiftUI 出類拔萃。當開發一個真實的 App 時,讓使用者註冊帳戶是很常見的。在本章中,我將建立一個帶有三個文字欄位的簡單註冊畫面。我們的重點是表單驗證,因此將不進行實際註冊,你將學習如何利用Combine 的強大功能,來驗證每個輸入欄位,並在視圖模型中編寫程式碼。
在深入研究程式碼之前,請先看一下圖 15.1,這是我們將建立的使用者註冊畫面。在每個輸入欄位下會列出要求,一旦使用者填入資訊後,App 就會即時驗證所輸入的資訊, 如果驗證結果正確,則欄位要求文字就會劃掉。而在滿足所有的要求之前,將先禁用「註冊」(Sign up )按鈕。
如果你對 Swift 與 UIKit 有一些經驗,你就會知道有各種類型的實作方式可處理表單驗證。不過,在本章中,我們將探索如何利用 Combine 框架來執行表單驗證。
我們從一個練習來開始本章,使用至目前為止所學的內容,來佈局如圖 15.1 所示的表單 UI。要在 SwiftUI 中建立一個文字欄位,你可以使用 TextField 元件,而對於密碼欄位, SwiftUI 提供了一個名為 SecureField
安全文字欄位。
要建立一個文字欄位,需要使用欄位名稱與綁定(binding )來初始化 TextField,這將渲染一個可編輯的文字欄位,而使用者輸入會儲存在給定的綁定中。和其他表單欄位類似,你可以透過使用相關的修飾器來修改它的外觀。下面是範例程式碼片段:
TextField("Username", text: $username)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
這兩個元件的用法非常相似,除了安全欄位會自動遮蔽使用者的輸入:
SecureField("Password", text: $password)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
我知道這兩個元件對你而言比較陌生,不過在觀看解答之前,試著盡力建立表單。
那麼,你能建立表單嗎?即使你無法完成這個練習,也沒有關係,現在至 https://www.appcoda.com/resources/swiftui5/SwiftUIFormRegistrationUI.zip 。來下載這個專案。我將會介紹我的作法讓你參考。
開啟 ContentView.swift
檔,並預覽畫布中的佈局,你渲染的視圖應該如圖 15.2 所示。現在,我們簡要檢視一下程式碼,並從 RequirementText
視圖開始。
struct RequirementText: View {
var iconName = "xmark.square"
var iconColor = Color(red: 251/255, green: 128/255, blue: 128/255)
var text = ""
var isStrikeThrough = false
var body: some View {
HStack {
Image(systemName: iconName)
.foregroundColor(iconColor)
Text(text)
.font(.system(.body, design: .rounded))
.foregroundColor(.secondary)
.strikethrough(isStrikeThrough)
Spacer()
}
}
}
首先,為什麼我對欄位要求文字建立一個單獨視圖(如圖 15.3 所示)?如果你檢視任何一個欄位要求文字,它都有一個圖示及一個敘述。我們無須從頭開始建立每一個欄位要求文字,我們可以將程式碼一般化,並為其建立一個通用的程式碼。
RequirementText
視圖有四個屬性,包括 iconName
、iconColor
、text
與 isStrikeThrough
。它足以彈性支援欄位要求文字的不同樣式,如果你接受預設的圖示與顏色,則可建立如下的欄位要求文字:
RequirementText(text: "A minimum of 4 characters")
這將渲染如圖 15.3 所示的欄位要求文字。在某些情況下,應要劃掉欄位要求文字,並顯示不同的圖示或顏色。程式碼可撰寫如下:
RequirementText(iconName: "lock.open", iconColor: Color.secondary, text: "A minimum of 8 characters", isStrikeThrough: true)
你可指定一個不同的系統圖示名稱、顏色,並將 isStrikeThrough
選項設定為 true
,這可讓你建立如圖 15.4 所示的欄位要求文字。
現在,你應該了解 RequirementText
視圖的工作原理,以及為何建立它。我們來看一下FormField 視圖。同樣的,如果你檢視所有的文字欄位,它們都有一個共同的樣式,即圓體字型樣式的文字欄位,這就是為何我取出一些通用程式碼,並建立 FormField
視圖的原因。
struct FormField: View {
var fieldName = ""
@Binding var fieldValue: String
var isSecure = false
var body: some View {
VStack {
if isSecure {
SecureField(fieldName, text: $fieldValue)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
} else {
TextField(fieldName, text: $fieldValue)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
}
Divider()
.frame(height: 1)
.background(Color(red: 240/255, green: 240/255, blue: 240/255))
.padding(.horizontal)
}
}
}
由於這個通用的 FormField
需要同時負責文字欄位與安全欄位,因此它有一個名為 isSecure
的屬性。如果設定為 true
,表單欄位將建立為安全欄位。在 SwiftUI 中,你可以使用 Divider
元件來建立一條線。在程式碼中,我們使用 frame
修飾器來變更高度為「1 點」。
要建立使用者名稱欄位,你可以將程式碼撰寫如下:
FormField(fieldName: "Username", fieldValue: $username)
對於密碼欄位,除了 isSecure
參數設定為 true 之外,程式碼也非常相似:
FormField(fieldName: "Password", fieldValue: $password, isSecure: true)
那麼,我們回到 ContentView
結構,來看表單是如何佈局。
struct ContentView: View {
@State private var username = ""
@State private var password = ""
@State private var passwordConfirm = ""
var body: some View {
VStack {
Text("Create an account")
.font(.system(.largeTitle, design: .rounded))
.bold()
.padding(.bottom, 30)
FormField(fieldName: "Username", fieldValue: $username)
RequirementText(text: "A minimum of 4 characters")
.padding()
FormField(fieldName: "Password", fieldValue: $password, isSecure: true)
VStack {
RequirementText(iconName: "lock.open", iconColor: Color.secondary, text: "A minimum of 8 characters", isStrikeThrough: true)
RequirementText(iconName: "lock.open", text: "One uppercase letter", isStrikeThrough: false)
}
.padding()
FormField(fieldName: "Confirm Password", fieldValue: $passwordConfirm, isSecure: true)
RequirementText(text: "Your confirm password should be the same as the password", isStrikeThrough: false)
.padding()
.padding(.bottom, 50)
Button(action: {
// Proceed to the next screen
}) {
Text("Sign Up")
.font(.system(.body, design: .rounded))
.foregroundColor(.white)
.bold()
.padding()
.frame(minWidth: 0, maxWidth: .infinity)
.background(LinearGradient(gradient: Gradient(colors: [Color(red: 251/255, green: 128/255, blue: 128/255), Color(red: 253/255, green: 193/255, blue: 104/255)]), startPoint: .leading, endPoint: .trailing))
.cornerRadius(10)
.padding(.horizontal)
}
HStack {
Text("Already have an account?")
.font(.system(.body, design: .rounded))
.bold()
Button(action: {
// Proceed to Sign in screen
}) {
Text("Sign in")
.font(.system(.body, design: .rounded))
.bold()
.foregroundColor(Color(red: 251/255, green: 128/255, blue: 128/255))
}
}.padding(.top, 50)
Spacer()
}
.padding()
}
}
首先,我們有一個 VStack
存放所有的表單元素,它由標題開始,接著是所有的表單欄位與欄位要求文字。我已經解釋過這些表單欄位與欄位要求文字是如何建立的,因此我將不再詳細說明。我加入欄位的是 padding
修飾器,這可以讓文字欄位之間加入間距。
「註冊」按鈕是使用 Button
元件所建立,目前沒有動作。我打算將這個動作閉包留空, 因為我們的重點是表單驗證。同樣的,我相信你應該知道如何自訂按鈕,因此我將不再對其詳細介紹,你可以隨時參考按鈕一章的內容。
最後,是「已經有帳號」的敘述文字,這個文字與「登入」按鈕並不一定需要,我只是想模仿常見的註冊表單。
以上是我佈局使用者註冊畫面的方式。如果你已試著做這個練習,或許可提出其他的解決方案,這完全沒問題。我只是告訴你建立表單的其中一種方法,你可以使用它作為參考,並提出更好的實作方式。
在深入研究表單驗證程式碼之前,我們先來了解更多關於 Combine 框架的背景資訊。如前一章所述,這個新框架提供了一個宣告式API,用於隨著時間處理值。
「隨著時間處理值」是什麼意思?這些值又是什麼?
我們以註冊表單為例。App 與使用者互動時,持續產生 UI 事件,假設使用者在文字欄位中輸入的每個按鍵,都會觸發一個事件,而這可以成為值的串流,如圖 15.5 所示。
這些 UI 事件是框架所參照的一種類型的值。這些值的另一個例子是網路事件(例如: 從遠端伺服器下載一個檔案)。
Combine 框架提供一個宣告式方法,供App 處理事件。你可以為給定的事件源建立單一處理鏈(chain),而不是實作多個委派回呼(delegate callback )或完成處理閉包(completion handler closure )。鏈的每一個部分都是一個Combine 運算子,對上一個步驟收到的元素執行不同的動作。
- Apple 的官方文件 (https://developer.apple.com/documentation/combine/receiving_and_handling_events_with_combine)
發布者與訂閱者是框架的兩個核心元素。使用 Combine,發布者傳送事件,訂閱者訂閱,以從發布者接收值。同樣的,我們以文字欄位為例。透過使用 Combine,使用者在文字欄位中輸入的每個按鍵,都會觸發一個值更改的事件。而對監控這些值感興趣的訂閱者,可以訂閱接收這些事件,並進一步執行操作(例如:驗證)。
舉例而言,你正在寫一個表單驗證器,它有指示表單是否準備好送出的屬性。在這個例子中,你可以使用@Published
標註來標記該屬性,如下所示:
class FormValidator: ObservableObject {
@Published var isReadySubmit: Bool = false
}
每次變更 isReadySubmit
的值時,它都會向訂閱者發布一個事件。訂閱者接收更新後的值,並繼續處理,例如:訂閱者可以使用該值來判斷是否應啟用「送出」按鈕。
你可能會覺得在 SwiftUI 中 @Published
和 @State
的運作方式非常相似。雖然在這個例子,它的工作原理幾乎相同,不過 @State
只能應用在屬於特定SwiftUI 視圖的屬性。如果你想要建立不屬於特定視圖或可以用於多個視圖的自訂類型的話,則你需要建立一個遵從 ObservableObject
的類別,並以 @Published
標註來標記這些屬性。
現在你應該具備 Combine 的一些基本觀念,我們來開始使用框架實作表單驗證,下列是我們將要做的事情:
我知道你心裡可能會有幾個問題。首先,為什麼我們需要建立視圖模型呢?我們可以在 ContentView 加入這些表單屬性並執行表單驗證嗎?
你絕對可以這樣做,但是隨著專案規模持續成長,或者視圖變得更加複雜,將複雜的元件拆成多層是一個良好作法。
「關注點分離」(Separation of concerns )是編寫優秀軟體的基本原則。我們可以把視圖分成視圖及其視圖模型等兩個元件,而不是將所有的東西放在一個視圖中。視圖本身是負責 UI 佈局,而視圖模型存放要在視圖中顯示的狀態與資料,並且視圖模型還處理資料驗證與轉換。對於有經驗的開發者而言,你知道我們正應用眾所周知的「MVVM」(Model- View-ViewModel 的縮寫)設計模式。
所以,這個視圖模型將保存哪些資料呢?
再看一次註冊表單,我們有三個文字欄位,包括:
除此之外,這個視圖模型將存放這些欄位要求文字的狀態,以指示是否應該刪掉它們:
因此,視圖模型將具有七個屬性,並且每一個屬性將其值的變更發布給那些有興趣接收值的人。視圖模型的基本架構可以定義如下:
class UserRegistrationViewModel: ObservableObject {
// Input
@Published var username = ""
@Published var password = ""
@Published var passwordConfirm = ""
// Output
@Published var isUsernameLengthValid = false
@Published var isPasswordLengthValid = false
@Published var isPasswordCapitalLetter = false
@Published var isPasswordConfirmValid = false
}
這就是表單視圖的資料模型。username
、password
與 passwordConfirm
屬性分別存放使用者名稱、密碼與密碼確認的文字欄位的值。這個類別應該遵從 ObservableObject
。所有這些屬性都使用 @Published
來做標註,因為我們想要在值發生變更時通知訂閱者,並相應執行驗證。
好的,以上為資料模型,不過我們還沒有處理表單驗證。我們要如何依照要求來驗證使用者名稱、密碼與密碼確認呢?
使用 Combine,你就必須培養發布者/ 訂閱者思維模式來解決問題。考慮到使用者名稱, 我們這裡其實有兩個發布者:username
與isUsernameLengthValid
。每當使用者在使用者名稱欄位上敲擊鍵盤輸入時,username
發布者就會發布值的變更,而 isUsernameLengthValid
發布者將使用者輸入的驗證狀態通知訂閱者。幾乎所有SwiftUI 中的控制元件都是訂閱者,因此欄位要求文字視圖將監聽驗證結果的變化,並相應更新其樣式(即是否有刪除線)。圖15.6 說明了我們如何使用 Combine 來驗證使用者名稱的輸入。
這裡缺少的是連接兩個發布者之間的東西,並且這個「東西」要處理下列的任務:
username
的變化。isUsernameLengthValid
。如果你將以上的需求轉換成程式碼,下面是程式碼片段:
$username
.receive(on: RunLoop.main)
.map { username in
return username.count >= 4
}
.assign(to: \.isUsernameLengthValid, on: self)
Combine 框架提供兩個內建的訂閱者:「sink」與「assign」。 sink
建立一個通用訂閱者來接收值,assign
則讓你建立另一種類型的訂閱者,用於更新物件的特定屬性。舉例而言,它將驗證結果(true/false )指定給 isUsernameLengthValid
。
我將深入說明並逐行檢視上列的程式碼。$username
是我們想要監聽的變更值的來源。由於我們訂閱 UI 事件的變化,因此呼叫 receive(on:)
函數來確保訂閱者在主執行緒(即 RunLoop.main
)上接收到值。
發布者傳送的值是使用者所輸入的使用者名稱,不過訂閱者感興趣的是使用者名稱的長度是否能夠滿足最低要求。這裡,map
函數被認為是Combine 中的運算子,它接受輸入、處理輸入,並將輸入轉換為訂閱者所期望的內容,因此我們在上列的程式碼中做了下列事情:
對於驗證結果, 訂閱者只需將結果設定給 isUsernameLengthValid
屬性。請記得 isUsernameLengthValid
也是一個發布者,我們可以更新 RequirementText
控制元件來訂閱變更,並相應更新 UI,如下所示:
RequirementText(iconColor: userRegistrationViewModel.isUsernameLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "A minimum of 4 characters", isStrikeThrough: userRegistrationViewModel.isUsernameLengthValid)
圖示的顏色與刪除線的狀態都取決於驗證結果而定(也就是 isUsernameLengthValid
)。
這就是我們如何使用 Combine 來驗證表單欄位的方式。我們還沒有將程式碼變更放入我們的專案中,不過我希望讓你了解發布者/ 訂閱者的觀念,以及我們如何使用這個方法執行驗證。在後面的小節中,我們將應用所學的知識,並對程式碼進行更改。
如果你了解使用者名稱欄位的驗證是如何完成的,我們將對密碼與密碼確認驗證應用類似的實作。
對於密碼欄位,有兩個要求:
為此,我們可以設定兩個訂閱者,如下所示:
$password
.receive(on: RunLoop.main)
.map { password in
return password.count >= 8
}
.assign(to: \.isPasswordLengthValid, on: self)
$password
.receive(on: RunLoop.main)
.map { password in
let pattern = "[A-Z]"
if let _ = password.range(of: pattern, options: .regularExpression) {
return true
} else {
return false
}
}
.assign(to: \.isPasswordCapitalLetter, on: self)
第一個訂閱者訂閱了密碼長度的驗證結果,並指定給 isPasswordLengthValid
屬性。第二個是處理大寫字母的驗證。我們使用 range
方法來測試密碼是否至少包含一個大寫字母。同樣的,訂閱者將驗證結果直接指定給屬性。
好的,剩下的是密碼確認欄位的驗證。對於這個欄位,輸入要求是密碼確認應與密碼欄位的密碼相同。password
與 passwordConfirm
都是發布者,為了驗證兩個發布者是否具有相同的值,我們使用 Publisher.combineLatest
來接收與結合來自發布者的最新值,然後我們可以驗證兩個值是否相同。下列為程式碼片段:
Publishers.CombineLatest($password, $passwordConfirm)
.receive(on: RunLoop.main)
.map { (password, passwordConfirm) in
return !passwordConfirm.isEmpty && (passwordConfirm == password)
}
.assign(to: \.isPasswordConfirmValid, on: self)
同樣的,我們指定驗證結果給 isPasswordConfirmValid
屬性。
現在我已經解釋了實作的方法,我們將所有內容放至專案中。首先,使用 「Swift File」 模板建立一個新 Swift 檔,命名為 UserRegistrationViewModel.swift
,然後使用下列程式碼替換整個檔案內容:
import Foundation
import Combine
class UserRegistrationViewModel: ObservableObject {
// 輸入
@Published var username = ""
@Published var password = ""
@Published var passwordConfirm = ""
// 輸出
@Published var isUsernameLengthValid = false
@Published var isPasswordLengthValid = false
@Published var isPasswordCapitalLetter = false
@Published var isPasswordConfirmValid = false
private var cancellableSet: Set<AnyCancellable> = []
init() {
$username
.receive(on: RunLoop.main)
.map { username in
return username.count >= 4
}
.assign(to: \.isUsernameLengthValid, on: self)
.store(in: &cancellableSet)
$password
.receive(on: RunLoop.main)
.map { password in
return password.count >= 8
}
.assign(to: \.isPasswordLengthValid, on: self)
.store(in: &cancellableSet)
$password
.receive(on: RunLoop.main)
.map { password in
let pattern = "[A-Z]"
if let _ = password.range(of: pattern, options: .regularExpression) {
return true
} else {
return false
}
}
.assign(to: \.isPasswordCapitalLetter, on: self)
.store(in: &cancellableSet)
Publishers.CombineLatest($password, $passwordConfirm)
.receive(on: RunLoop.main)
.map { (password, passwordConfirm) in
return !passwordConfirm.isEmpty && (passwordConfirm == password)
}
.assign(to: \.isPasswordConfirmValid, on: self)
.store(in: &cancellableSet)
}
}
以上的程式碼幾乎與前面章節的程式碼相同。要使用 Combine,你需要先匯入 Combine 框架。在 init()
方法中,我們初始化所有的訂閱者來監聽文字欄位的值的變更,並執行相應的驗證。
程式碼與我們之前討論的程式碼幾乎相同,但你可能注意到一件事,即 cancellableSet
變數。對於每一個訂閱者,我們在最後呼叫 store
函數。
這些是什麼呢?
assign
函數建立訂閱者,並回傳一個可取消的實例。你可以使用該實例在適當的時間取消訂閱。store
函數可讓我們將可取消的的參照儲存到一個集合中,以便稍後的清理。如果你不儲存這個參照,則 App 可能出現記憶體洩漏的問題。
那麼,這個範例何時會進行清理呢?由於 cancellableSet
被定義為該類別的屬性,因此當類別被取消初始化時,將會對訂閱進行清理及取消。
現在切換回 ContentView.swift
,並更新 UI 控制元件。首先,將下列的狀態變數:
@State private var username = ""
@State private var password = ""
@State private var passwordConfirm = ""
以一個視圖模型取代,並命名為 userRegistrationViewModel
:
@ObservedObject private var userRegistrationViewModel = UserRegistrationViewModel()
接下來,更新文字欄位與使用者名稱的欄位要求文字如下:
FormField(fieldName: "Username", fieldValue: $userRegistrationViewModel.username)
RequirementText(iconColor: userRegistrationViewModel.isUsernameLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "A minimum of 4 characters", isStrikeThrough: userRegistrationViewModel.isUsernameLengthValid)
.padding()
現在,fieldValue
參數已經變更為 $userRegistrationViewModel.username
。對於欄位要求文字,SwiftUI 監控 userRegistrationViewModel.isUsernameLengthValid
屬性,並相應更新欄位要求文字。
同樣的,更新密碼與密碼確認欄位的UI 程式碼如下所示:
FormField(fieldName: "Password", fieldValue: $userRegistrationViewModel.password, isSecure: true)
VStack {
RequirementText(iconName: "lock.open", iconColor: userRegistrationViewModel.isPasswordLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "A minimum of 8 characters", isStrikeThrough: userRegistrationViewModel.isPasswordLengthValid)
RequirementText(iconName: "lock.open", iconColor: userRegistrationViewModel.isPasswordCapitalLetter ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "One uppercase letter", isStrikeThrough: userRegistrationViewModel.isPasswordCapitalLetter)
}
.padding()
FormField(fieldName: "Confirm Password", fieldValue: $userRegistrationViewModel.passwordConfirm, isSecure: true)
RequirementText(iconColor: userRegistrationViewModel.isPasswordConfirmValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "Your confirm password should be the same as password", isStrikeThrough: userRegistrationViewModel.isPasswordConfirmValid)
.padding()
.padding(.bottom, 50)
如此,現在可以測試 App 了。如果你已正確進行所有的修改,則 App 現在應該能驗證使用者輸入,如圖 15.7 所示。
我希望你現在已經掌握了一些 Combine 框架的基本認識。SwiftUI 與 Combine 的導入徹底改變了建立 App 的方式。函式響應式程式設計(Functional Reactive Programming,簡稱 FRP),近年來越來越流行,不過這是Apple 首次釋出自己的函式響應式框架。在我看來,這是一個重要的典範轉移(paradigm shift )。Apple 公司最終對 FRP 做出定位,並推薦 Apple 開發者擁抱這個新的程式設計方法。
就像導入任何一個新技術的導入一樣,會有一個學習曲線,即使你之前有 iOS 開發經驗,也要花一些時間從委派的程式設計方法變成到發布者/ 訂閱者的設計方法。
不過,一旦你管理了 Combine 框架,你將會非常高興,因為它可以幫你實現更可維護與模組化的程式碼,與SwiftUI 一起使用,如你所見,視圖與視圖模型之間的溝通變得輕而易舉了。
在本章所準備的範例檔中,有完整的專案可供下載: