#43 Drinks Order APP 訂飲料 − Part 2

with AirTable API

繼上一篇 Drinks Order APP 訂飲料 − Part 1

本篇將介紹:

選擇甜度時,使用 Picker View 搭配 Tool Bar

利用 sheetPresentationController 顯示特定高度的 Topping Table View Controller

輸入備註欄位時,讓畫面自動向上捲動,防止鍵盤遮住 Text Field

確認訂單後利用 navigation controller 返回 menu

透過 AirTable 串接 Rest API,包含上傳新訂單(API POST),讀取訂單(API GET),刪除訂單(API DELETE)

選擇甜度時,使用 Picker View 搭配 Tool Bar

當點選 Text Field 時,把原本應該出現的鍵盤,變成 Picker View 搭配 Tool Bar 可以取消與選擇。

首先把 Picker View 跟 Tool Bar 加入要顯示的 controller 的 Exit 下面, Tool Bar 裡可以加入其他 Bar Button Item 以及用 Fixed Space Bar Button Item 隔開。

之後在 controller 上面會多出 Picker View 跟 Tool Bar

之後把 Picker View 、Tool Bar 拉好 IBOutlet,兩個 Bar Button Item 拉好 IBAction,就可以從程式控制它們了

記得讓 Order View Controller 當 Picker View 的 dataSource 跟 delegate

在 viewDidLoad 裡輸入下面兩行,讓點選 sugarLevel Text Field 時,本來會出現的鍵盤被 Picler View 跟 Tool Bar取代

        sugarLevel.inputView = pickerView
sugarLevel.inputAccessoryView = toolBar

接下來,用 UIPickerViewDataSource 決定 Picker View 呈現出來得樣子

extension OrderViewController: UIPickerViewDataSource {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
1
}

func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
sugarLevels.count
}
}

用 UIPickerViewDelegate 裡的 function pickerView titleForRow 設定點選時的功能

extension OrderViewController: UIPickerViewDelegate {
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
sugarLevels[row].rawValue
}
}

點選 sugar Text Field 時顯示 Picker View

    @IBAction func sugarTextField(_ sender: Any) {
view.becomeFirstResponder()
}

兩個 Bar Button Item,分別顯示 Cancel 跟 Done,
點 Cancel 時利用 view.editing(true) 收回 Picker View;
點 Done 時讓 sugarLevel Text Field 顯示選取的選項

    //tap Cancel button to dismiss picker view
@IBAction func pressCancel(_ sender: Any) {
view.endEditing(true)
}
//tap Done button to show the selection on sugarLevel Text Field and dismiss picker view
@IBAction func pressDone(_ sender: Any) {
let row = pickerView.selectedRow(inComponent: 0)
sugarLevel.text = sugarLevels[row]
view.endEditing(true)
}

選擇配料時,顯示特定高度的 table view

當選擇配料時,要顯示自訂高度的 ToppingTableViewController 也是用到 sheetPresentationController,只是高度是選擇 .custom 就可以自訂想要的高度,這裡是設定 300

    @IBSegueAction func showTopping(_ coder: NSCoder) -> ToppingTableViewController? {
let controller = ToppingTableViewController(coder: coder)
if let sheetPresentationController = controller?.sheetPresentationController {
sheetPresentationController.detents = [.custom(resolver: { _ in
300
})]
}
return controller
}

顯示的配料可以不選,但最多只能選兩樣,將會被儲存到 selectedToppings array,注意在取消已選取的配料時,同時移除 selectedToppings array 項目,會需要用到 array 的 function .firstIndex(where: ) 找到在 selectedToppings array 裡的 index,之後就可以刪除該項目。

程式碼會寫在 func tableView didSelectedRowAt 裡,同時每次點選就會改變topping check 的 Bool 值

        let row = indexPath.row
//add toppings
if selectedToppings.count < 2, toppings[row].check == false {

toppings[row].check = true
selectedToppings.append(toppings[row])

//remove topping
} else if toppings[row].check == true {

toppings[row].check = false
let name = toppings[row].toppingName

//finding the index of the same topping
if let index = selectedToppings.firstIndex(where: {
$0.toppingName.contains(name) }) {
selectedToppings.remove(at: index)
}
}

tableView.reloadData()

最後,也要把資料傳回前一頁,在前一頁要顯示已選取的配料以及讀取改變 check Bool 值的 toppings ,使再次進入配料選單時顯示已選取的項目,看起來更自然些。

        let name = Notification.Name("topping changed")
NotificationCenter.default.post(name: name, object: nil, userInfo: [
"selectedToppings" : selectedToppings,
"toppings": toppings
])

輸入備註欄位時,讓畫面自動向上捲動,防止鍵盤遮住 Text Field

要做到鍵盤出現時畫面自動上移,避免遮住 Text Field 的功能,需要用到 scroll view 及其屬性 contentInset,contentInset 可在 scroll view 的四個邊與其他元件之間增加距離,而距離也可以自訂。

搭配用 NotificationCenter 接收每次鍵盤出現(包含鍵盤尺寸訊息)以及消失時在程式裡發出的訊息,就能知道何時畫面該向上推移以及回復原位

    func registerForKeyboardNotifications() {
NotificationCenter.default.addObserver(self,selector: #selector(keyboardWasShown(_:)),name: UIResponder.keyboardDidShowNotification,object: nil)
NotificationCenter.default.addObserver(self,selector: #selector(keyboardWillBeHidden(_:)),name: UIResponder.keyboardWillHideNotification,object: nil)
}

@objc func keyboardWasShown(_ notificiation: NSNotification) {
guard let info = notificiation.userInfo,
let keyboardFrameValue = info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }

let keyboardFrame = keyboardFrameValue.cgRectValue
let keyboardSize = keyboardFrame.size

let movingDistance = keyboardSize.height-(view.frame.size.height-scrollView.frame.maxY)

scrollView.contentInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: movingDistance, right: 0.0)
}

@objc func keyboardWillBeHidden(_ notification: NSNotification) {
scrollView.contentInset = UIEdgeInsets.zero
}

先算出要推移的距離,存入常數 movingDistance

 let keyboardFrame = keyboardFrameValue.cgRectValue
let keyboardSize = keyboardFrame.size

let movingDistance = keyboardSize.height-(view.frame.size.height-scrollView.frame.maxY)

透過 AirTable 串接 Rest API,包含上傳新訂單(API POST),讀取訂單(API GET),刪除訂單(API DELETE)

這裡一樣參考 Apple 官方範例,利用 Result type 來串接 Rest API。

新增一個 class OrderController,裡面定義 static var shared 方便在別的 controller 也能呼叫。把上傳新訂單(API POST),讀取訂單(API GET),刪除訂單(API DELETE) 都寫成function。

class OrderController {

static let shared = OrderController()

private let apiValue = "Bearer key12345678R"

func fetchOrderList(comletion: @escaping (Result<[OrderRecord], Error>) -> Void) {

guard let url = URL(string: "https://api.airtable.com/v0/appBBoJvZRrxzj09q/Order?sort[][field]=time&sort[][direction]=desc") else {return}
var request = URLRequest(url: url)

request.httpMethod = "GET"
request.setValue(apiValue, forHTTPHeaderField: "Authorization")

URLSession.shared.dataTask(with: request) { data, response, error in

if let error {
comletion(.failure(error))
} else if let data {
do {
let decoder = JSONDecoder()
let getOrder = try decoder.decode(GetOrder.self, from: data)
comletion(.success(getOrder.records))
} catch {
comletion(.failure(error))
}
}
}.resume()
}

func postOrder(createOrder: CreateOrder.Record, completion: @escaping (Result<Response, Error>) -> Void) {

guard let url = URL(string: "https://api.airtable.com/v0/appBBoJvZRrxzj09q/Order") else {return}
var request = URLRequest(url: url)

request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiValue, forHTTPHeaderField: "Authorization")

let newOrder = createOrder

let encoder = JSONEncoder()

request.httpBody = try? encoder.encode(newOrder)

URLSession.shared.dataTask(with: request) { data, response, error in

if let error {
completion(.failure(error))
} else if let data {
do {
let decoder = JSONDecoder()
let response = try decoder.decode(Response.self, from: data)
completion(.success(response))
} catch {
completion(.failure(error))
}
}
}.resume()
}

func deleteOrder(deleteID: String) {

guard let url = URL(string: "https://api.airtable.com/v0/appBBoJvZRrxzj09q/Order/\(deleteID)") else {return}
var request = URLRequest(url: url)

request.httpMethod = "DELETE"
request.setValue(apiValue, forHTTPHeaderField: "Authorization")

URLSession.shared.dataTask(with: request) { data, response, error in
if let httpResponse = response as? HTTPURLResponse {
print("🟡httpResponse.statusCode", httpResponse.statusCode)
}
if let data {
print("🟢content",String(data: data, encoding: .utf8)!)
}
}.resume()
}
}

· 新增訂單

呼叫 OrderController.shared.postOrder

       OrderController.shared.postOrder(createOrder: createOderContent) { result in
switch result {
case .success(let response):
print(response)
case .failure(let error):
print(error)
}
}

最後,當完成新訂單時,希望頁面自動返回 menu 頁面,頁面前有加入 navigation controller 所以可用 popToRootViewController 返回

self.navigationController?.popToRootViewController(animated: true)
create order

· 讀取訂單

可以先清空資料,然後呼叫 OrderController.shared.fetchOrderList

    func renewDate() {

//empty orderRecords and table view first
self.orderRecords = []
self.tableView.reloadData()

OrderController.shared.fetchOrderList { result in

switch result {
case .success(let orderRecords):
self.orderRecords = orderRecords
DispatchQueue.main.async {
self.tableView.reloadData()
}
case .failure(let error):
print(error)
}
}
}

顯示在 Order List Table View Controller 裡

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of rows
orderRecords.count
}

fileprivate func configuration(_ cell: OrderListTableViewCell) {
cell.frameImageView.layer.cornerRadius = 10
cell.frameImageView.layer.borderWidth = 4
cell.frameImageView.layer.borderColor = CGColor(red: 147/255, green: 189/255, blue: 197/255, alpha: 1)
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(OrderListTableViewCell.self)", for: indexPath) as? OrderListTableViewCell else {
fatalError("dequeueReusableCell OrderListTableViewCell failed")
}

let row = indexPath.row

cell.nameLabel.text = "#\(row+1) "+orderRecords[row].fields.name
cell.toppingLabel.text = orderRecords[row].fields.toppings
cell.sizeLabel.text = orderRecords[row].fields.size+" x "+orderRecords[row].fields.quantity.description
cell.iceLevelLabel.text = orderRecords[row].fields.iceLevel
cell.sugarLavelLabel.text = orderRecords[row].fields.sugarLevel
cell.noteLabel.text = orderRecords[row].fields.note
cell.timeLabel.text = orderRecords[row].fields.time

configuration(cell)

return cell
}

· 刪除訂單

呼叫 OrderController.shared.deleteOrder

寫進刪除表格的 func tableView commit editingStyle ,原本的 array 也要移除該筆資料、表格要更新

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {

let id = orderRecords[indexPath.row].id

OrderController.shared.deleteOrder(deleteID: id)

orderRecords.remove(at: indexPath.row)

tableView.deleteRows(at: [indexPath], with: .automatic)

}
delete order

--

--