#42 Drinks Order APP 訂飲料 − Part 1

with AirTable API

APP 功能部分:

🌟 登入時顯示完整 menu

🌟 可以點選飲料類別,僅顯示該類別的商品

🌟 可輸入關鍵字查詢特定商品名稱,顯示符合的商品

🌟訂購畫面可以調整甜度、冰塊、配料、大小、數量以及輸入備註

🌟 按下訂購按鈕後,顯示確認視窗,選擇返回或是上傳訂單

🌟 可查看已訂購訂單頁面,可刪除訂單

Demo:

Left: create order; Right: delete order

本篇將介紹:

在 AirTable 建立menu,以及訂單,共兩份表單

透過 AirTable 產生 API,閱讀 API 文件

參考 Apple 官方範例,利用 Result type 串接 Rest API

使用 segue 切換下一頁畫面及 prepare 傳資料到下一頁

利用 sheetPresentationController 顯示一半高度的 Category table view controller

使用 NotificationCenter 傳送出資料訊息以及在上一頁接收資料

利用 dismiss 返回上一頁

在 AirTable 建立 menu 跟 order 列表

menu 的來源是參考新加坡當地的手搖品牌 LiHo(發音像福建話的你好)

在 AirTable 新增一個登入帳戶,一步步的建立資料,一開始有點困惑,當建立好訂單列表的項目時,但還沒有內容,這樣就好了嗎?
是的,訂單列表起初就只有項目,也就完成了。

menu and order lists in AirTable

透過 AirTable 產生 API,閱讀 API 文件

建立好資料後,還需要取得API Key 跟閱讀 API 文件,點選右上角的帳戶圖示,然後選擇 Developer hub

然後取的專屬的 token 或是 API key。之後點選 Web API documentation 進入 API 文件的說明頁面

在頁面下方可以看到現有已建立的資料

點選後就會出現 API 文件的說明頁面,在左邊可以看到之前建立好的列表名稱 menu 跟 order,它們下面的子項目分別說明應用在 API method 上 request 的使用方式。

耐心閱讀資料,知道如何使用就可以進入程式的部分了。

reference:

參考 Apple 官方範例,利用 Result type 串接 Rest API

會用到 Result type 的寫法,例如當使用到 try? 時,若執行結果有錯誤發生,則會變成 nil,不會回傳 error ,自然也不知道原因;因此 Apple 在 “ Develop in Swift Data Collections 12 ” 裡使用了 Result type 的寫法,為的就是請求無法被滿足時,還能回傳錯誤。

Result type,是 Apple 寫好的 enum,包含兩個 case :.success(Success) 跟 .failure(Failure),當我們在定義Result時,它的 associated value 會用到 generic。

取得在 AirTable 上的 menu,

建立 Class MenuController 讀取資料及上傳資料到 AirTable,裡面定義 static var shared 方便在別的 controller 也能呼叫。

class MenuController {

static let shared = MenuController()
private let apiValue = "Bearer 12345678R"

func fetchMenu(urlString: String, completion: @escaping (Result<[Record], Error>) -> Void) {

guard let url = URL(string: urlString) else {return}

var request = URLRequest(url: url)
request.setValue(apiValue, forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error {
completion(.failure(error))
} else if let data {
let decoder = JSONDecoder()
do {
let getMenu = try decoder.decode(GetMenu.self, from: data)
completion(.success(getMenu.records))
} catch {
completion(.failure(error))
}
}
}.resume()
}

enum MenuItemError: Error, LocalizedError {
case imageDataMissing
}

func fetchImage(url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error {
completion(.failure(error))
} else if let data,
let photo = UIImage(data: data) {
completion(.success(photo))
} else {
completion(.failure(MenuItemError.imageDataMissing))
}
}.resume()
}

}

這裡得做法是呈現在 view controller 裡的 collection view

在 view controller 裡,呼叫 MenuController 的 fetchMenu function,一開始顯示所有商品

    var records = [Record]()
var presentItems = [Record]()
var categories = ["All Products"]
var urlString = "https://api.airtable.com/v0/appBBoJvZRrxzj09q/Menu?sort%5B0%5D%5Bfield%5D=category&sort%5B0%5D%5Bdirection%5D=desc"

func update(records: [Record]) {
self.records = records
presentItems = records
collectionView.reloadData()
getCategory()
}

func initializeUI() {
MenuController.shared.fetchMenu(urlString: urlString) { result in

switch result {
case .success(let records):
DispatchQueue.main.async {
self.update(records: records)
}
case .failure(let error):
print(error)
}
}
}

view controller 裡,用 collection view 呈現出商品

extension MenuViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
presentItems.count
}

fileprivate func configuration(_ cell: MenuCollectionViewCell) {
cell.activityIndicator.hidesWhenStopped = true
cell.activityIndicator.startAnimating()

cell.itemPhoto.image = nil //clear images

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)
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(MenuCollectionViewCell.self)", for: indexPath) as? MenuCollectionViewCell else {fatalError("dequeueReusableCell MenuCollectionViewCell failed")}

configuration(cell)

let presentItem = presentItems[indexPath.row]

cell.itemName.text = presentItem.fields.name
cell.itemPrice.text = "\(presentItem.fields.price)"

if let imageURL = presentItem.fields.image.first?.url {

MenuController.shared.fetchImage(url: imageURL) {[weak self] result in
guard let self else {return}

switch result {
case .success(let photo):
DispatchQueue.main.async {
cell.itemPhoto.image = photo
cell.activityIndicator.stopAnimating()
}

case .failure(let failure):
print(failure)
}
}
}
return cell
}
}

在擷取影像(fetchImage)的部分,利用 capture list 搭配 weak ,在 closure 裡的開頭加入 [weak self] ,若是 view controller 消失時,一併停止取得影像,減少不必要的記憶體使用。這裡的 self 代表所在的 view controller。

之後在搭配 Search BarCategories 就可以篩選想要呈現的商品

Search Bar − 搜尋特定商品

先讓 MenuViewController 當 search Bar 的 delegate,用到 Search bar delegate 的func searchBar(UISearchBar, textDidChange: String)

裡面搭配 for-in 跟 if,把符合名稱的商品存入 presentItems 裡,重新呈現出來。

extension MenuViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
presentItems = []
collectionView.reloadData()

if searchText.isEmpty == true {
presentItems = records
categoryButton.setTitle("All Products", for: .normal)
} else {
categoryButton.setTitle("Filtered", for: .normal)

for record in records {
if record.fields.name.lowercased().contains(searchText.lowercased()) {
presentItems.append(record)
}
}
}
collectionView.reloadData()

}
//tap return to dismiss keyboard
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
view.endEditing(true)
}

//tap view to dismiss keyboatrd
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
view.endEditing(true)
}
}

使用 segue 切換下一頁畫面及 prepare 傳資料到下一頁

先在 MenuViewController 建立一個 function,裡面用 for-in 把所有商品的類別組成一個array

func getCategory() {
for record in records {
let category = record.fields.category.first!
if categories.contains(category) == false {
categories.append(category)
}
}
print(categories)
}

印出會得到這樣得結果

[“All Products”, “Cheezho”, “MiLK TEA”, “Brew Tea”, “Red Bull”, “FRUiTEA”, “Choco / Coffe”, “FRESH MiLK TEA / OAT MiLK TEA”, “Superhei-ro Unite”]

現在要傳遞得資料有了

定義一個 table view controller 型別為 CategoryTableViewController,目的是顯示所有商品的類別。在上一頁新增按鈕用 segue 連接,去切換畫面,在搭配 class UIViewController 底下的 func prepare 就可以同時傳遞資料。

要傳遞的頁面間需要有同樣型別的屬性,就可以傳遞資料;依據目的地的不同,傳遞不同的資料。

另一個目的地是 OrderViewController 是顯示點飲料細節的畫面。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

if let destination = segue.destination as? CategoryTableViewController {

destination.categories = categories

} else if let destination = segue.destination as? OrderViewController,
let item = collectionView.indexPathsForSelectedItems?.first?.item {
destination.record = presentItems[item]
}
}

在選擇飲料類別,顯示一半高度的 Category table view controller

在下一頁 Category Table View Controller 顯示 Categories array,當點選 Category 按鈕時顯示該頁面。

特別的是,想要 Category Table View Controller 顯示特定高度,從 iOS 15 開始可使用 sheetPresentationController 負責控制用 present 顯示畫面及 sheetPresentationController 的 detents 設定頁面的高度,拉 IBSegueAction function 在裡面定義:

    @IBSegueAction func showCategotyTableViewController(_ coder: NSCoder) -> UITableViewController? {

let controller = CategoryTableViewController(coder: coder)

if let sheetPresentationController = controller?.sheetPresentationController {
sheetPresentationController.detents = [.medium()]
}
return controller
}

高度有.large、.medium跟.custom 可以選

使用 NotificationCenter 傳送出資料訊息以及在上一頁接收資料

Category Table View Controller 顯示出來後,當特定 cell 被點選時,利用 NotificationCenter.default.post 傳送被選擇的 category。

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

let row = indexPath.row

let category = categories[row]

let name = Notification.Name("selected category")
NotificationCenter.default.post(name: name, object: nil, userInfo: [
"category" : category
])

dismiss(animated: true)
}

希望點選 cell 後自動返回上一頁,用 present 進入下一頁,所以用 dismiss 返回上一頁。

在 MenuViewController 用 NotificationCenter.default.addObserver 接收資料,特別注意其參數 selector 必須是 objc function。

一樣是用 for-in 跟 if 把符合 Category 的商品加入 presentItems array 重新顯示出來。

    func selectedCategory() {
let name = Notification.Name("selected category")
NotificationCenter.default.addObserver(self, selector: #selector(categoryChanged), name: name, object: nil)
}

@objc func categoryChanged(noti: Notification) {

presentItems = []
collectionView.reloadData()

if let userInfo = noti.userInfo,
let category = userInfo["category"] as? String {

categoryButton.setTitle(category, for: .normal)

if category == "All Products" {
presentItems = records
} else {
for record in records {
if record.fields.category.first == category {
presentItems.append(record)
}
}
}
}
collectionView.reloadData()
}

最後把 func selectedCategory( ) 寫進 viewDidLoad 裡

--

--