33.%d Xcode — Data Binding

春麗 S.T.E.M.
17 min readDec 8, 2021

--

目錄

⦿ 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 。看到如下:

自行體會變成 array 的 listeners 與原先的 listener 有何差異吧

此外,我們還可以透過型別別名(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 RxCocoa
private 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事件(動作)。

--

--

春麗 S.T.E.M.
春麗 S.T.E.M.

Written by 春麗 S.T.E.M.

Do not go gentle into that good night, Old age should burn and rave at close of day; Rage, rage, against the dying of the light.

No responses yet