#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

小技巧:若要在Xcode裡以色盤修改元件顯示顏色,可輸入#colorLiteral(

為避免打錯字,或不知從哪複製,每當在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

用current( )方法生成通知中心的實體

遵循協定、請求授權

雖然黃色警告,可能將來不再支援此舊版寫法,但先將就著用。

這邊就可以看到剛才的請求授權的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

最後的最後

--

--

Chun-Li 春麗
彼得潘的 Swift iOS / Flutter App 開發教室

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.