33.%d Xcode — Data Binding
目錄
⦿ Observer Pattern 的使用情境
⦿ Observer Pattern
⦿ RxSwift
⦿ BehaviorRelay
⦿ Observable combinedLatest
Observer Pattern 的使用情境
我們知道當專案變得很大時,MVC Architecture 漸漸不敷使用,比方元件與元件的連動,可能會變得不容易管理 。
試想在一個登入畫面的三個欄位,輸入帳號
、密碼
、驗證碼
,若希望在三個欄位皆有輸入的時候,才開放登入按鍵
的點選,點選後,若沒正確輸入驗證碼、驗證碼過期,或者其他錯誤時跳出提示訊息,這也許有點複雜了吧?
所以我們必須想辦法把權責分開
,先看到如下:
藉由三個 TextField 連到同一個 IBAction,來去做 loginButton isEnabled 的判斷,這看起來還不是那麼複雜,但假設第二個欄位也必須判斷開放輸入與否
,比方第二個欄位在某種情況下才開放使用者輸入。
如此一來,邏輯變成第二個欄位有在的時候,判斷三個
欄位不為空時 loginButton enabled;若第二個欄位不在的時候,判斷第一三個
欄位不為空時,loginButton enabled。
用 UI 來判斷 UI 的開放與否的確有點麻煩,我們可以試著用前篇文章提過的 Observer Pattern
來做,重要的是——把權責分清楚。
MVC Architecture 下,使用 UIKit,當 Data 改變時,我們常希望去更新 View,比方取回的 Table 在資料更新時,我們要 reload TableView,所以,如果 View 能夠跟 Data 是綁定的,做好這項管理,程式碼看起來相對舒服。
在 SwiftUI 中,@State、@Binding 就能做到當 Data 改變時,UI 因應而變,這就是我們一直在說的 Data Binding,不過,在 UIKit 中就得自己實作 Observer Pattern
,又或者使用 RxSwift
這個套件了。
繼續閱讀|回目錄
Observer Pattern
先新建一個 Swift,class 取名為 Observable
(Box),如下:
class Observable<T> {
var value: T? {
didSet {
print("\(value)")
}
} init(_ value: T) {
self.value = value
}
}
這個 Observable 是一個泛型,表示在創建實例時可以採用各種型別
,再利用屬性觀察器讓 value 在建立後印出自己的值,寫完這段可以放在 VC 的 viewDidLoad 中測試看看,如下:
若你有在 didSet 中加入 print,會發現創建實例時,只有賦值給 value 時才會印出 value,創建實例之際,即 let name = Observer(“Tom”) / let name = Observerr(Sring()),並不會 print 出結果,所以我們可以透過增加閉包的方式去監聽這個值,變成如下:
class Observer<T> {
var value: T? {
didSet {
listener?(value)
}
}
init(_ value: T) {
self.value = value
}
private var listener: ( (T?) -> Void )? func bind(_ listener: @escaping (T?) -> Void) {
listener(value)
self.listener = listener
}
}
閉包的參數也是 T,沒有返回值,當 value didSet 的時候,將 value 帶入閉包,意思是這個 value 透過 listener 做監聽,當 value 改變時,listener 都知道。
不過我們還需要監聽當前的值
或初始值
,而不是只有在它改變時才會知道,所以加入一個 bind function 後,讓它的參數型別跟 listener 同,self.listener = listener 作用是 call back
。
在使用這個 bind function 時,self.listener = listener 是回頭去監聽當 value 改變時,我要做什麼事;listener(value) 則是說當前的 value 我也要監聽。
看到如下:
是吧?Tom 當前的值透過 bind 就可以監聽到 value 並把它 print 出來,接著更改 value 仍然可透過 listener 不斷更新它,繼續把它 print 出來。
在前篇文章的這個段落,我們說過這樣還不能
解決多重綁定
的問題,只能做單一物件(元件)的綁定,所以改成如下:
class Observer<T> {
var value: T? {
didSet {
listeners.forEach {
$0(value)
}
}
}
init(_ value: T) {
self.value = value
}
private var listeners: [ (T?) -> Void ] = []
func bind(_ listener: @escaping (T?) -> Void) {
listener(value)
self.listeners.append(listener)
}
}
將 listener 變為 listeners 即是多個閉包,那麼在使用 bind 時就是加入新的 binding,也就是後來的 bind 動作不會去覆蓋前一個 bind 的動作,但應當注意此時每一個 bind 都是新的動作,所以必須好好安排你的 Data Binding 。看到如下:
此外,我們還可以透過型別別名(typealias)去美化
我們的程式碼,如下:
class Observer<T> {
typealias listener = (T?) -> Void
var value: T? {
didSet {
listeners.forEach {
$0(value)
}
}
}
init(_ value: T) {
self.value = value
}
private var listeners: [ listener ] = []
func bind(_ listener: @escaping listener) {
listener(value)
self.listeners.append(listener)
}
}
看起來是不是更易讀了呢?
繼續閱讀|回目錄
使用 Observable
那麼,回到最初的問題,在 IBAction 中我們可以怎麼做呢?
@IBOutlet weak var firstTextField: UITextField!
@IBOutlet weak var secondTextField: UITextField!
@IBOutlet weak var thirdTextField: UITextField!
@IBOutlet weak var loginButton: UIButton!
// So ugly,且使用姿勢不對
@IBAction func judgeBtnState() {
let areFieldsFilled = Observer(
firstTextField.text != "" && secondTextField.text != "" && thirdTextField.text != ""
)
areFieldsFilled.bind { [weak self] isFilled in
self?.loginButton.isEnabled = isFilled ?? false
}
}
我們觀察一個布林值,布林值代表所有的 TextField 都不是空的,如果都不是空的,我們透過這個布林值綁定 loginButton,以此判定 isEnabled,但是這個 Observer 太醜了,並且如果這些 TextField 沒有要做其他事,一個一個以 @IBOutlet 連接,似乎意義不大。
更重要的,不該每次 TextField 的值改變時去重新 bind
loginButton,這樣邏輯擠在一起有點義大利麵,試著再改動它,如下:
@IBOutlet var inputTextFields: [UITextField]!
@IBOutlet weak var loginButton: UIButton!
private var areFieldsFilled: Bool {
inputTextFields.allSatisfy { textField in
!(textField.text?.isEmpty ?? true)
}
}
private var buttonStateObserver: Observer<Bool>?
override func viewDidLoad() {
super.viewDidLoad()
buttonStateObserver = Observer(false)
buttonStateObserver?.bind{ [weak self] isFilled in
self?.loginButton.isEnabled = isFilled ?? false
}
}
@IBAction func judgeBtnState() {
buttonStateObserver?.value = areFieldsFilled
}
第一個是宣告我們要觀察對象的狀態
,並在 viewDidLoad 初始化它(false),狀態跟對象綁定在一起,對象此刻跟著初始化
。
接著,每當 TextField 有輸入時,都去改變對象的狀態的值
,這個值是由這些 TextField 而來,當然,在他們都不是空的時候,狀態的值才是 true,而對象會跟著 Enabled。
結果如下:
Cool!這看起來很棒吧!
來試試看其他寫法,如下:
@IBOutlet weak var firstTextField: UITextField!
@IBOutlet weak var secondTextField: UITextField!
@IBOutlet weak var thirdTextField: UITextField!
@IBOutlet weak var loginButton: UIButton!private var textFields = [UITextField]()
private var allFieldsObserver: Observer<[UITextField]>?override func viewDidLoad() {
super.viewDidLoad()
textFields.append(contentsOf: [firstTextField, secondTextField, thirdTextField]) allFieldsObserver = Observer(textFields)
allFieldsObserver?.bind { textFields in
let fieldArray = textFields?.filter { $0.text != "" }
self.loginButton.isEnabled = (fieldArray?.count == 3)
}
}@IBAction func judgeBtnState() {
allFieldsObserver?.value = [firstTextField, secondTextField, thirdTextField]
}
既然可以觀察 Bool,我們也可以觀察這些 TextField,當 TextField 的 Text 改變時,在滿足條件的情況下,我們去改變 loginButton。
初始化這些被觀察的 TextField,在它發生變化時,我都重新加入觀察,這個程式碼看起來有點詭異,但它做到某種程度的權責分離
及抽象
,因為其實是在改變 TextField 的 Text,但又好像什麼都沒變。
很奇妙吧?
繼續閱讀|回目錄
RxSwift
在使用 RxSwift 前,請記得先服用:
import RxSwift
import RxCocoaprivate var fieldsFilledRelay = BehaviorRelay(value: false)此外,記得要在 class 裡面宣告一個
private let disposeBag = DisposeBag()
用以移除觀察
BehaviorRelay
我們觀察一個 Bool,將這個 Bool 與 loginButton 綁定,每當 TextField 改變時,這個 Bool 也會跟著改變,而我們已經有 areFieldsFilled 這個屬性,來計算是不是三個 TextField 都不為空
,若是,就是 true。
程式碼如下:
private var areFieldsFilled: Bool {
inputTextFields.allSatisfy { textField in
!(textField.text?.isEmpty ?? true)
}
}
private var fieldsFilledRelay = BehaviorRelay(value: false)
override func viewDidLoad() {
super.viewDidLoad()
fieldsFilledRelay.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
}
@IBAction func judgeBtnState() {
fieldsFilledRelay.accept(areFieldsFilled)
}
我們宣告一個行為中繼
,它代表的是當 TextField 都不為空,初始化的 false 就會變成 true,而它是用 areFieldsFilled 來判斷真值,所以每當 TextField 改變時,行為中繼
就會接收 areFieldFilled 的值。
我們將行為中繼
與 loginButton.rx.isEnable 綁定在一起,所以行為中繼
改變時,isEnable 也會跟著改變。
Observable combineLatest
而其實我們可以更直接地去觀察 inputTextFields 是否欄位皆不為空,以此與 loginButton 綁定,程式碼如下:
let combinedTexts = Observable.combineLatest(inputTextFields.map { $0.rx.text.orEmpty })
combinedTexts.map { textFields in
textFields.allSatisfy { !$0.isEmpty }
}.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
當你宣告一個 combinedTexts,它是 Observable 的所有 UITextField 的 text 組成的 Array,看到如下:
public static func combineLatest<Collection: Swift.Collection>(_ collection: Collection) -> Observable<[Element]>
where Collection.Element: ObservableType, Collection.Element.Element == Element {
return CombineLatestCollectionType(sources: collection, resultSelector: { $0 })
}
因為 combineLatest 會回傳最新的
Observable 的 Element,所以 $0.rx.text
即是 Element(這裡有三個),而 .orEmpty
則是為了去掉 Optional。
我們看看 combinedTexts
是什麼型別,如下:
Observable 是一個泛型,裡面是 ControlProperty<String>.Element 的 Array(其實就是 String Array) 的陣列,但此時 combinedTexts
並不是一個我們要與 loginButton.rx.isEnabled 綁在一起的型別,就好像 Bool 不會跟 String 互相綁定,它還需經過計算。
所以再透過 map 將它皆不為空
是否是事實作為返回值(Bool),再與 loginButton 綁定,最後會得到相同結果。
RxSwift
提供多種監聽的方式,你可以將 UITextField 的 Text(String)是不是都不為空作為 Observable(可觀察的),或者直接把結果的 Bool 作為 Observable,再與 loginButton.rx.isEnabled
綁定,待 Text(String) 或 Bool 有變化時,isEnabled 也會跟著變化。
也可把 UITextField 的 Text 的變化過程當作一個 Driver,接著去驅動(drive)事件(動作),這個事件是當三個 TextField 是否皆不為空,再來確定 loginButton isEnabled
。
值(Model)與狀態(State)用 bind(綁定);如果把 Text 的變化當作 Driver(asDriver),或是當成可觀察的(asObservable),則分別驅動(drive)
或訂閱(subscribe)
事件(動作)。