#18 Xcode 訂飲料 App — 以Firebase為例
Resful API、Local Notification、stepper、swipe、UNUserNotification
cout<<” 以下使用的圖片、gif 用以練習,非商業用途 ”<<endl;
因為之前已用過讀取本地JSON資料,由於這次商品資料少,暫將資料宣告在controller裡,像這樣
先新增一個swift file,寫ProductData
struct ProductData {
let imgName: String
let name: String
var price: String
let content: String
let description: String
}
自訂cell
將商品顯示於tableviewcell
為避免打錯字,或不知從哪複製,每當在storyboard設定了ID,在程式裡也可以這樣寫
struct cellKey {
static let MenuTableViewCell = "MenuTableViewCell"
}
如此在ID的地方便可用自動完成。或者採用字串插值,如下:
(withIdentifier: ”\(MenuTableViewCell.self)”
使用guard let生成可循換利用的cell,否則回傳另生成的cell實體。
設定cell的每列,let cellRow = contents[indexPath.row],便可把內容放進去了。無聊地把內容印出來,如下:
也無聊地看看剛才說的小技巧 #colorLiteral( 是什麼
cell.pdPriceLabel.textColor = colorLiteral(red: 1, green: 0.7408380418, blue: 0, alpha: 1)
第一頁寫完後,接著要傳值到下一頁,會用到@IBSegueAction,先來看看第二頁怎麼做。
宣告了一個productData為ProductData類型,ProductData,重述一次,寫在一個swift file裡面
struct ProductData {
let imgName: String
let name: String
var price: String
let content: String
let description: String
}
那麼,為了傳值,第二頁可以這麼做
init?(_ coder: NSCoder, productData: ProductData, _ database: Firestore) {
self.productData = productData
self.db = database
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder: ) has not been inplemented.")
}
初始化MenuDetailTableViewController這個class,讓裡面的內容方便接收,怎麼接收呢?上一頁的SegueAction
@IBSegueAction func dataPassed(_ coder: NSCoder) -> MenuDetailTableViewController? { guard let row = tableView.indexPathForSelectedRow?.row else { return nil } let productData = contents[row]
return MenuDetailTableViewController(coder, productData: productData, bd)}
當segue產生後,就宣告一個變數productData,於是return的地方,就必須return參數代入變數的下頁class,下頁的init便是用在這,於是在viewDidLoad裡
coder表示是由storyboard產生的,這個方法稍嫌麻煩的是,在下頁宣告的變數都必須初始化了,例如想用資料庫而宣告的db也得初始化,本來想以convience init來改寫,但沒試成功,所以傳值的地方,改用第二方法。
回到上頁的segueaction
@IBSegueAction func dataPassed(_ coder: NSCoder) -> MenuDetailTableViewController? {
guard let row = tableView.indexPathForSelectedRow?.row else { return nil }
let controller = MenuDetailTableViewController(coder: coder)
controller?.productData = contents[row]
return controller
}
生成下頁controller的實體,去給參數賦值,於是第二頁便可以顯示上頁的詳細資料。
這邊順便練習了stepper,但原先的資料price是以 $ 做為金額顯示,想轉成貨幣格式,於是寫了一個轉換方法,用以更新ui。
func updatePriceLabel(priceData: String, senderValue: Double?) {let moneySubStr = priceData.dropFirst()let moneyStr = String(moneySubStr)let moneyDouble = Double(moneyStr)guard let moneyDouble = moneyDouble else { return }let formatter = NumberFormatter()formatter.locale = Locale(identifier: "zh_tw")formatter.numberStyle = .currencyISOCode//顯示為沒有小數點的貨幣formatter.maximumFractionDigits = 0if senderValue != 0 {let dtPriceStr = formatter.string(from: NSNumber(value: moneyDouble * (senderValue! + 1)))dtPriceLabel.text = dtPriceStr} else {let dtPriceStr = formatter.string(from: NSNumber(value: moneyDouble))dtPriceLabel.text = dtPriceStr}//顯示訂購量productCountLabel.text = " " + "\(Int(dtStepper.value + 1))" + "杯"}
沒想到priceData不能使用removeFirst( )去移除 $,所以先用使用subString的方法,再轉string,再轉double。中間不提了,有頭有尾就好,講太多會變成義大利麵,後面是顯示訂購量,即杯數。
而第三頁的顯示內容是從資料庫讀取,所以不需要用第二頁傳值,所以將一些資料都丟到資料庫去。
Firebase的設定可參考
完成後要記得在AppDelegate裡configure( ),使用到資料庫功能的第二、三頁也要記得導入函式庫。
那麼,第二頁欲將資料丟上firestore會這樣寫
@IBAction func orderSended(_ sender: UIButton) {
if orderNameTextField.text?.trimmingCharacters(in: .whitespaces).isEmpty == false {
db?.collection("orderList").addDocument(data: [
"orderName": orderNameTextField.text ?? "",
"drinkName": dtNameLabel.text ?? "",
"drinkSize": orderSizeSegCon.titleForSegment(at: orderSizeSegCon.selectedSegmentIndex) ?? "",
"sugar": orderSugarSegCon.titleForSegment(at: orderSugarSegCon.selectedSegmentIndex) ?? "",
"cold": orderIceSegCon.titleForSegment(at: orderIceSegCon.selectedSegmentIndex) ?? "",
"add": orderAddSegCon.titleForSegment(at: orderAddSegCon.selectedSegmentIndex) ?? "",
"price": dtPriceLabel.text ?? ""
]) { error in
if let error = error { print(error) }
}
self.performSegue(withIdentifier: "orderSendedDB", sender: nil)
} else {
let alert = UIAlertController(title: "注意", message: "姓名欄不得為空", preferredStyle: .alert)
let alertAction = UIAlertAction(title: "上一步", style: .cancel, handler: nil)
alert.addAction(alertAction)
present(alert, animated: true, completion: nil)
print("購買人欄位為空")
}
儲存資料的地方,暫時想不到更好的寫法,不確定能否一行就addDocument( ),也許同類outlet拉在一起,寫方法去更新資料,但不會比較方便易懂。
由於必須判斷姓名欄位是否留空,採用performSegue去顯示下頁內容,按下送出訂單,有滿足條件才跳轉頁面。
讓欄位去trim有沒有留白,如果是下面這樣都不行,除非有其他字元進去,才可以讓你將資料傳到資料庫
" "
" "
" "
" "
" "
在firestore的地方,記得修改規則,讓它可以寫入
write: if true,並且在專案裡新增collection為你想新增資料內容進去的collection名稱。
以db讀取collection(“名稱”),再.addDocument( )新增資料進去
"drinkName": dtNameLabel.text ?? "",
"drinkSize": orderSizeSegCon.titleForSegment(at: orderSizeSegCon.selectedSegmentIndex) ?? ""
然後判斷元件內容,為避免其中一個元件內容為nil,用三元運算子,預設內容為空,來讓程式不會死掉。
前面黑體字即是要新增value進去的key的名稱,結果如下
崩潰——為什麼是json不是jason!
冷笑話後回到正題,後面宣告alert沒什麼特別,就是要使用alertController。
第三頁
如果要找資料一個一個賦值可能稍嫌麻煩,所以宣告一個orderDatas為OrderData的陣列實體
所以在另一個swift file裡寫了
struct OrderData: Codable, Identifiable {
@DocumentID var id: String?
var orderName: String
var drinkName: String
var drinkSize: String
var sugar: String
var cold: String
var add: String
var price: String
}
讓他遵循兩個協議,並在id前方@DocumentID,這樣id便可以讓firebase識別,接下來講怎麼用。
寫了一個fetchData( )方法,裡面採用getDocuments( ),並用較新的方法給予orderDatas資料
func fetchData() {
db.collection("orderList").getDocuments { (querySnapshot, error) in
guard let querySnapshot = querySnapshot else { return } let orderDatas = querySnapshot.documents.compactMap { queryDocumentSnapshot in try? queryDocumentSnapshot.data(as: OrderData.self) }
self.orderDatas = orderDatas
DispatchQueue.main.async {
self.tableView.reloadData()
}
print(self.orderDatas)
}
for document in querySnapshot.documents {
print(document.data())
}
使用compactMap去try?讀到的一個個data,轉成自訂型別OrderData,最後將這個集合orderDatas去賦值給第三頁一開始說的生成的實體,如此便完成取值了。
最後別忘了在主線程reload tableview的data,將取資料方法放到viewDidLoad裡,方能取資料並更新tableview。
上面的getDocument( )方法,如果要一個一個加的話,會寫成這樣
self.documentId.append(document.documentID) self.orderNameList.append(document.data()["orderName"] as? String ?? "")
self.drinkNameList.append(document.data()["drinkName"] as? String ?? "")
self.drinkSizeList.append(document.data()["dirnkSize"] as? String ?? "")
self.sugarList.append(document.data()["sugar"] as? String ?? "")
self.coldList.append(document.data()["cold"] as? String ?? "")
self.addList.append(document.data()["add"] as? String ?? "")
self.priceList.append(document.data()["price"] as? String ?? "")
}
有一個新的,在firebase自生成的id加進來,然後各個屬性形成的集合一個個加入讀到的資料,但是這個資料一開始還不是string,必須經過轉換,轉換後,有值取值,無值為空白。
寫起來麻煩多了。
這個orderNameList就是,orderDatas裡的一個row裡的一個屬性的集合,連中文說起來都稍嫌囉唆。而用.compactMap,就可以把從資料庫中取得的資料轉換成自定義型別而成的集合。雖然裡面還是經過兩重轉換
querySnapshot.documents.compactMap再去try? queryDocumentSnapshot.data(as: OrderData.self)
不過不用我們自己來。
取到的資料自然是幫cell賦值,沒什麼特別的
guard let cell = tableView.dequeueReusableCell(withIdentifier: cellKey.OrderDetailTableViewCell, for: indexPath) as? OrderDetailTableViewCell else { return UITableViewCell() } let orderData = orderDatas[indexPath.row]
cell.portraitImgView.image = UIImage(named: "00"+"\(Int.random(in: 1...8))") cell.portraitImgView.layer.cornerRadius = 125 / 2
cell.orderNameLabel.text = orderData.orderName cell.orderNameLabel.textColor = #colorLiteral(red: 0.7450980544, green: 0.1568627506, blue: 0.07450980693, alpha: 1) cell.drinkNameLabel.text = orderData.drinkName
cell.drinkSizeLabel.text = orderData.drinkSize
cell.sugarLabel.text = orderData.sugar
cell.coldLabel.text = orderData.cold
cell.addLabel.text = orderData.add
cell.priceLabel.text = orderData.price
return cell
UIImage(named: “00”+”\(Int.random(in: 1…8))”)是隨機幫用戶產生頭像。
再來是這個
如果要實作tableview cell的swipe,會用到
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { }
這個func便是在說cell尾端可滑動,裡面則寫你要的action,這邊只看delete的action就好
let deleteAction = UIContextualAction(style: .destructive, title: "刪除") { (action, view, completionHandler) in
guard let id = self.orderDatas[indexPath.row].id else { return }
self.db.collection("orderList").document(id).delete { error in
if let error = error {
print("Error: \(error)")
} else {
print("Current list has been deleted!")
}
} self.orderDatas.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade) completionHandler(true)
}
跟alert類似,在firebase的地方,前面有了增、讀,這邊要來刪,使用每個訂單的id去刪除在datastore的資料。
這個id,當然也是orderDatas的其中一個row的id屬性,再來刪除這個選到的row,tableview用淡出動畫,移除這個row,顯示的內容就少一列了。
但是,還要記得completionHandler(true),否則滑開的cell會沒辦法復原。
最後,把action加進去建構swipeAction裡面回傳,在此之前也可設定fullSwipe為true,就自己測試囉。
UISwipeActionsConfiguration(actions: [deleteAction, doNothingAction]).performsFirstActionWithFullSwipe = true return UISwipeActionsConfiguration(actions: [deleteAction, doNothingAction])
再來順便玩一下本地通知
在AppDelegate中記得導入UserNotifications
遵循協定、請求授權
雖然黃色警告,可能將來不再支援此舊版寫法,但先將就著用。
這邊就可以看到剛才的請求授權的options,是個集合,跟swipeAction一樣,只是一個是action的集合,一個是更新選項。
最後是回到第二頁的orderSended( )這個方法(不要太在意文法,ed結尾是用來表明這個動作已經發生即可),裡面增加資料到資料庫後,本地通知會跳出來
let content = UNMutableNotificationContent()
content.title = "wow!恭喜您~"
content.subtitle = "訂購成功"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) let request = UNNotificationRequest(identifier: "noti", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
print("成功建立前景通知")
}
設定彈出通知的時間(秒)、標題與副標,做為代入的參數,生成請求通知的實體,加入通知中心。
再來欣賞一下可愛的波妞吧。
以下reference
最後的最後