#35 Firebase 仿交友軟體介面與 RxSwift 初探(純程式)

RxSwift / RxCocoa / Programmatic UI / Firebase

Ethan
17 min readJan 3, 2022

Model

Managers

AuthManager

❖ 跟上篇文稍稍不同,這次在註冊階段就從 authDataResult 的 user 中取得 uid,並馬上呼叫:

UserManager.shared.createRegData(uid: uid, name: name, email: email, completion: nil)

存到 Firestore。(函式後述)

❖ 未來若要取用 uid 可以直接呼叫下面函式:

func getCurrentUid() -> String? {
return Auth.auth().currentUser?.uid
}

UserManager

❖ 剛剛提到的 createRegData 函式,它把 uid, name, email, createdAt 都存到 Firestore,作為第一批個人資料。

❖ 未來若要更新個人資料,可以把想要更新的欄位做成字典傳入 updateUserInfo 函式。

❖ 讀取個人資料以及讀取其他使用者資料。分別回傳 User? 和 [User]。

技巧是利用 filter 比對每個 user 與登入者的 uid 來篩掉登入者。

PhotoManager

❖ uploadProfileImage 函式負責將個人頭貼上傳到 Storage 下的自訂 path,再從 path 獲得 url 的 absoluteString,將它建立成個人資料的新 key — value pair。(利用剛剛提過的 updateUserInfo 函式)

AppDelegate

這次沒有製作 Loading Screen,且用純程式刻畫面,所以跳轉邏輯要寫在 AppDelegate。

❖ setRootViewController 可以傳入跳轉目標 view controller,並設成 self.window 的 rootViewController,接著 self.window 呼叫 makeKeyAndVisible()。若 animated 為 true 時,要多加下面這行:

UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve, animations: nil, completion: nil)

❖ 跳轉邏輯:

如果開 App 發現已登入要跳到 homeNav,未登入則跳到 regNav。

if LocalStorage.shared.hasLoggedIn {
let homeNav = UINavigationController(rootViewController: homeVC)
setRootViewController(homeNav)
} else {
let regNav = UINavigationController(rootViewController: registerVC)
setRootViewController(regNav)
}

❖ 設定 Notification 觀察者:

NotificationCenter.default.addObserver(self, selector: #selector(didLogin), name: K.NotificationName.login, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(didLogout), name: K.NotificationName.logout, object: nil)

登入或登出成功時會發送 Notification 給觀察者,接著呼叫 didLogin / didLogout 改變 hasLoggedIn 狀態,並且利用 setRootViewController 函式跳到指定 view controller。

extension AppDelegate {
@objc func didLogin() {
LocalStorage.shared.hasLoggedIn = true
let homeNav = UINavigationController(rootViewController: homeVC)
setRootViewController(homeNav)
}
}
extension AppDelegate {
@objc func didLogout() {
LocalStorage.shared.hasLoggedIn = false
let regNav = UINavigationController(rootViewController: registerVC)
setRootViewController(regNav)
}
}

View Controllers

RegisterViewController

❖ signupAndCreateRegData :

為註冊並創造註冊資料的函式,在按下註冊按鈕後呼叫。若註冊成功,要發送 Notification 到剛剛 AppDelegate 內的觀察者呼叫跳轉。

❖ Reactive Programming:

import RxSwift 後,我們將 text fields / buttons / view model 都進行綁定。

  1. text field 的 driver 為 text,根據 text 驅動 viewModel 的 observer 監控下個送到 observer 的字串 text。(利用 func onNext(_ element: String)

2. registerButton 的 driver 為 tap,驅動 signupAndCreateRegData 函式;

3. hadAccountButton 的 driver 為 tap,驅動 push loginVC。

4. viewModel 的 driver 為 validRegisterDriver,根據 validAll 布林值驅動 registerButton 並改變其背景色。

RegiserViewModel

問題:allValid 的值是怎麼得到的?

看看 RegiserViewModel,原來用了 validRegisterSubject(BehaviorSubject<Bool>(value: false)型別)onNext 方法去監控 allValid 值。

⭐︎ 注意如何用 map、combineLatest、subscribe 去操作 observable sequence。

這邊簡單設定 name 字串至少要一位,email 和 password 字串至少要六位。

LoginViewController

與 RegisterViewController 內容差不多,就不多介紹。

HomeViewController

讀取個人(user) 和讀取他人(users)。

❖ 個人的部分,只要將 user 存成 property 就好。

❖ 他人的部分,把 users 存成 property 後,要確保 cardContainerView 的每個 subview 皆執行了 removeFromSuperview 且 removeConstraints。再將每個 user 匯入 CardView,然後加進 cardContainerView + auto layout。

CardView 介紹

❖ 初始卡片上有的元件。

setup(user: User)

功用是資料顯示及設定 UIPanGestureRecognizer。抓圖部分用 SDWebImage。

❖ 左滑右滑動作設定(panCardView 函式):

往右滑:要拖曳卡片、逆時針轉、‘LIKE’ 標籤顯現。

往左滑:要拖曳卡片、順時針轉、‘NOPE’ 標籤顯現。

結束拖曳:

若 x 平移量大於等於正負 150,卡片要旋轉、掉落、消失。(用到下方的 UIView Extension)

若 x 平移量小於正負 150,要回到原位、‘LIKE’、‘NOPE’ 標籤隱形。

卡片旋轉、掉落、消失的 UIView Extension
panCardView 函式

❖ UI

cardImageView 用到自己寫的 CardImageView,裡面是上到下、透明到黑漸變的 gradientLayer,如下程式所示。

CardInfoLabel 也是自己寫的類別,用來產生此畫面的標籤,不提。

class CardImageView: UIImageView {
private let gradientLayer = CAGradientLayer()

override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
layer.cornerRadius = 10
contentMode = .scaleAspectFill
clipsToBounds = true
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func layoutSubviews() {
gradientLayer.colors = [UIColor.clear.cgColor, UIColor.black.cgColor]
gradientLayer.locations = [0.5, 1.0]
layer.addSublayer(gradientLayer)
gradientLayer.frame = self.bounds
}
}

TabControlView 介紹

即底部那排像 Tab Bar 的 View,其實內容就是把 buttons 放到 Stack View。各 buttons 用到下面函式生成,點選後會更換顯示圖片。

func makeTabButton(imageName: String, unselectedImageName: String) -> UIButton {
let button = UIButton(type: .custom)
button.setImage(UIImage(named: imageName), for: .selected)
button.setImage(UIImage(named: unselectedImageName), for: .normal)
button.imageView?.contentMode = .scaleAspectFit
return button
}

點選各個 button,會傳入 button 自己到 handleSelectedButton 函式比對按鈕陣列。若對於 button == selectedButton,則 button.isSelected = true,否則 = false。

ControlView 介紹

即五個 buttons 各自放進一個 view,五個 view 再一起放進一個 stack view。值得注意的是,當按鈕 isHighlighted 為 true(按住不放) 要做縮小動畫,為 false(放開) 要做變回原大小動畫

按鈕綁定

值得注意的是要用全域變數 isCardAnimating 創造一個執行卡片移除動畫時,不會被再次點選喜歡或不喜歡按鈕而造成出錯的區塊。

ProfileViewController

用傳過來的 user 顯示畫面

編輯區是一個 infoColletionView,裡面有一個 cell(InfoCollectionViewCell)

❖ 使用到一個把 stack view 中的子 stack view 組起來的技巧。而 ProfileLabel 和 ProfileTextField 都是自訂類別,就不提。

❖ infoColletionView:

lazy var infoColletionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.dataSource = self
cv.backgroundColor = .white
cv.isScrollEnabled = false
cv.register(InfoCollectionViewCell.self, forCellWithReuseIdentifier: K.CellID.InfoCollectionViewCell)
return cv
}()

❖ UICollectionViewDataSource:

import RxSwift,把 cell 內每個 text field 的 text全域變數(name / age / residence / hobby / introduction / email)做綁定。

extension ProfileViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = infoColletionView.dequeueReusableCell(withReuseIdentifier: K.CellID.InfoCollectionViewCell, for: indexPath) as! InfoCollectionViewCell
cell.user = self.user // user 傳給 cell 的user
setupCellBindings(cell: cell) // 把編輯的內容與全域變數同步

return cell
}
private func setupCellBindings(cell: InfoCollectionViewCell) {
cell.nameTextField.rx.text
.asDriver()
.drive { [weak self] text in
self?.name = text ?? ""
}
.disposed(by: disposeBag)
cell.ageTextField.rx.text
.asDriver()
.drive { [weak self] text in
self?.age = text ?? ""
}
.disposed(by: disposeBag)
cell.residenceTextField.rx.text
.asDriver()
.drive { [weak self] text in
self?.residence = text ?? ""
}
.disposed(by: disposeBag)
cell.hobbyTextField.rx.text
.asDriver()
.drive { [weak self] text in
self?.hobby = text ?? ""
}
.disposed(by: disposeBag)
cell.introductionTextField.rx.text
.asDriver()
.drive { [weak self] text in
self?.introduction = text ?? ""
}
.disposed(by: disposeBag)

cell.emailTextField.rx.text
.asDriver()
.drive { [weak self] text in
self?.email = text ?? ""
}
.disposed(by: disposeBag)
}
}

觸發動作

❖ 點選儲存後,要用這些全域變數製作字典並呼叫 updateUserInfo 上傳。如果有用 image picker 換過頭貼了(用全域變數 hasChangedImage 判斷),就呼叫 uploadProfileImage。最後,名字標題也要更新。

❖ 按頭貼編輯按鈕和登出按鈕,前一篇文章做過就不細談。

--

--

Ethan
Ethan

Written by Ethan

Life is what happens to you while you’re busy making other plans.