iOS| #25 | 約翰紅茶訂餐App 2.0–Part.2 點餐與購物車篇
不久前已經實作過訂飲料App初版,那麼這次要利用目前所學的技能將其升級為2.0,並且加上數個功能使其成為一個更完整的產品。
本篇文章主要會說明點餐與購物車的UI畫面與功能實現。
由於此系列文篇幅會較長,會拆分成多篇文章紀錄實作的過程與功能講解
技術應用
- Programming Autolayout
- Programming UIKit
- StackView
- TableView
- AutoLayout
- Airtable REST API — CRUD
UI畫面
- Login Page
登入
- User Page
使用者資訊
- Menu Page
菜單
- Choose Page
選擇飲料屬性(EX: 冰塊、甜度等等)
- Cart Page
購物車
功能解說&程式解說
Login Page & User Page:
登入介面與使用者資料介面邏輯相似且較為單純,在這邊一起說明。
(登入功能目前規劃只要輸入任意使用者名稱即可,正式登入功能會在後續找時間補上)
- Login Page & User Page Textfield檢查機制
拉好Login Page至User Page的Segue
拉好SegueAction
收鍵盤
userNameTextField.resignFirstResponder()
實作func shouldPerformSegue(withIdentifier identifier: , sender: )
檢查若沒有輸入使用者名稱就show alert訊息,且return false(停留在這一頁)
if userNameTextField.text!.isEmpty { //alert NetWorkController.shared.showAlert(title: "警告", message: "請輸入使 用者名稱") { alert in self.present(alert, animated: true, completion: nil) } return false}
由於此App有許多地方都會需要show alert訊息,於是將其寫在struct NetWorkController裡面可供重複使用
//show alertfunc showAlert(title: String , message: String ,handler: (UIAlertController) -> Void ){ let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let ok = UIAlertAction(title: "OK", style: .default) alertController.addAction(ok) alertController.view.tintColor = .systemBlue handler(alertController)}
若有輸入,則將名稱 pass 給global 變數 g_headerInfo,且return true(去下一頁)
//keep 使用者名稱g_headerInfo.userName = userNameTextField.textreturn true
User Page的檢查機制與Login Page相同,因此不再贅述。
Menu Page:
- 根據AitTable設定檔 — DrinkType動態生成飲料類別Button
原始資料
新增測試資料
App相對新增Button
技術解說請參考iOS| #24 | 搭配動畫的水平滑動底線按鈕與頁面控制
以下將說明動態產生UIButton的方式
首先各產生一個ScrollView與StackView,並讓ScrollView包住StackView
拉Stackview的@IBOutlet
在viewDidLoad()抓Menu資料,成功抓到資料後呼叫method setMenuButton(index: , records: )動態建立UIButton至buttonsStackView中
抓資料程式碼解說可以參考iOS| #25 | 約翰紅茶訂餐App 2.0–Part.1 串接Airtable API,以下將會說明動態建立UIButton至buttonsStackView
產生UIButton元件
let button = UIButton()
設定button的title attribute
button.setAttributedTitle(NSAttributedString(string: records.fields.typeName, attributes: [.foregroundColor: UIColor(named: "AccentColor")!, .font: UIFont(name: "Songti TC Bold", size: 15)!]), for: .normal)
把index值給tag,日後將會利用tag判斷切換頁面與更新資料
button.tag = index
設定點擊button後要處理的動作 (method clickMenu 後續會再做說明)
button.addTarget(self, action: #selector(self.clickMenu), for: .touchUpInside)
將button加進stackView中。
注意:這邊要用addArrangedSubview而不是addSubview
//stackView要使用addArrangedSubviewbuttonsStackView.addArrangedSubview(button)
以下將說明method clickMenu的相關程式
每當點擊event touchUpInside發生時,會執行此method
@objc func clickMenu(sender: UIButton){currentPage = sender.tagsetUnderLineView(index: sender.tag)setItemTableViewController(currentpage: sender.tag)}
在此頁面的ViewController宣告屬性currentPage,將前面設定好的button屬性tag作為換頁的依據,把值給currentPage。
currentPage = sender.tag
改變underLineView的位置(詳細解說請參考iOS| #24 | 搭配動畫的水平滑動底線按鈕與頁面控制)
setUnderLineView(index: sender.tag)
根據點選的button切換對應的飲料類別Menu資料(在後續段落會詳細解說)
setItemTableViewController(currentpage: sender.tag)
- 根據AitTable設定檔 — Menu動態生成Menu
將AirTable — Menu資料維護齊全
透過欄位type與AirTable — DrinkType的欄位typeID做mapping顯示菜單
method setItemTableViewControllert(currentpage: ) 解說:
承前述說明,當點擊button時會呼叫此method clickMenu(sender:),而在此method中還會呼叫setItemTableViewController(currentpage: )切換頁面中的資料
@objc func clickMenu(sender: UIButton){currentPage = sender.tagsetUnderLineView(index: sender.tag)setItemTableViewController(currentpage: sender.tag)}
利用ViewController的children屬性跑迴圈。
children可以得出在自己Controller底下ContainerView中容納的ViewController元件。
self.children.forEach { viewController in 想要做的動作 }
取出ItemTableViewController
guard let itemTableViewController = viewController as? ItemTableViewController else { return }
透過currentPage取出該分類的飲料菜單,getItemMenu(from: )參考以下說明
guard let itemMenu = getItemMenu(from: currentPage) else { return }
利用Array的method filter() 過濾資料,利用欄位type、typeID將對應分類的資料篩選出來
//根據飲料分類取得相對應的品項func getItemMenu(from currentPage: Int) -> [Menu.Records]? {let records = menu?.records.filter({ result inreturn result.fields.type == drinkTypes?.records[currentPage].fields.typeID})return records}
由於是共用同一個tableViewController,所以當我們在不同分頁間滑動時,要將ContentOffset滑到最上面
//換頁要滑到最上面itemTableViewController.tableView.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
在ItemTableViewController中宣告屬性currentMenu,承接要顯示的值,並且把已過濾好的值pass給currentMenu
//給item值itemTableViewController.currentMenu = itemMenu
給完值以後要更新tableView才會顯示出來
//更新tableviewitemTableViewController.tableView.reloadData()
讓tableView更新資料時有動畫效果(by sections)
此例只有一個section,所以可以大膽的給參數IndexSet(integer: 0),得出第一個Section
//向右滑入cell動畫itemTableViewController.tableView.reloadSections(IndexSet(integer: 0), with: .right)
Choose Page:
- 根據設定檔資料決定輸出樣式
在parent(ChooseViewController)中決定children(ChooseTableViewController)的UI畫面
屬性Children的使用方式可以參考前述MENU解說
在containerView中的tableViewController實作func initialUI()
進入此頁面時已經得到設定檔資料了,在func initialUI()中將會利用屬性item初始化UI輸出的樣式
根據MENU設定檔的欄位判斷是否為固定的冰塊、甜度、可否加珍珠。
都是呼叫同一個func setButtonsDisable(tag: , buttons: , equal: )
tag:Button的屬性tag值(在Interface Builder手動維護)。
以冰塊為例子會與Enum Ice做mapping控制button的狀態
buttons:拉好的OutletCollections。(下圖以冰塊為例)
equal:判斷等於tag設為不可點擊;不等於tag設為不可點擊。
根據條件設定button的圖片,因為是系統的圖片,所以要用systemName來生成
button.setImage(UIImage(systemName: "checkmark.circle.trianglebadge.exclamationmark"), for: .normal)
將button設為不可選取
button.isEnabled = false
- Radio Button實作
由於Swift官方沒有Radio Button,於是在這邊自己實作
tag:Button的屬性tag值(在Interface Builder手動維護)。
buttons:拉好的OutletCollections。
利用for迴圈判斷被點選之button圖示改為”checkmark.circle.fill”,其餘改為”checkmark.circle”。
因為前面已經初始化UI畫面了,可能有部分飲料品項為固定的甜度、冰度。初始化的當下就已經將UIButton的state設為disabled,所以在這邊要下where條件排除這些用不到的UIButton。
for button in buttons where button.state != .disabled { if button.tag == tag { button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .normal) }else{ button.setImage(UIImage(systemName: "checkmark.circle"), for: .normal) }}
當點擊UIButton時更新UI畫面(以冰塊為例)
實現radioButton功能
radioButtonAction(tag: sender.tag, buttons: iceCheckButtons)
keep選擇的冰塊屬性(currentIceAttribute宣告為Class ChooseTableViewController本身的屬性)
currentIceAttribute = Ice.getIce(int: sender.tag)
更新UI Button的畫面
冰塊的判斷會比較特別,當選擇熱飲的時候(tag = 4)尺寸就只能選擇M,於是在這邊先判斷選擇熱飲要將M尺寸(tag = 0)設成Enable,且currentSizeAttribute要設為nil;L尺寸(tag = 1)設成Disable。
若選擇其他冷飲的話就將L尺寸(tag = 1)設成Enable。
//熱飲的話不能選擇L尺寸if sender.tag == 4 { setButtonsEnable(tag: 0, buttons: sizeCheckButtons) setButtonsDisable(tag: 1,buttons: sizeCheckButtons, equal: true) currentSizeAttribute = nil}else{ //如果選擇熱飲以外的,讓尺寸L可以被選擇 for button in sizeCheckButtons where button.tag == 1{ if !button.isEnabled { setButtonsEnable(tag: 1, buttons: sizeCheckButtons) } }}
func setButtonsEnable(tag: , buttons: )
與前述說明的func setButtonsDisable十分相似,都是更換Button圖片與控制屬性isEnabled
func setButtonsEnable(tag: Int, buttons: [UIButton]){ //指定tag的button可被點擊 for button in buttons where button.tag == tag{ button.setImage(UIImage(systemName: "checkmark.circle"), for: .normal) button.isEnabled = true }}
利用parent屬性生成ChooseViewController,並且重新計算金額(後續會做解說)
let chooseViewController = parent as! ChooseViewControllerchooseViewController.countPrice()
- 根據數量、品項屬性動態計算當前金額
當選擇品項的尺寸、增加或減少數量、加珍珠等等都會影響到當前的金額,所以在這邊實作func countPrice(),只要該品項與金額相關的屬性改變就會呼叫它更新金額。
數量控制邏輯
使用兩個UIButton控制數量增減,一個UILabel負責顯示數量
宣告變數currentQuantity,記住當前數量
這兩個Button會共用同一個@IBAction
利用UIButton的tag判斷數量增減(0為減;1為加)。
減的部分要注意數量沒有負的,所以要判斷算出來小於0的話要改成0。
//減if sender.tag == 0 { currentQuantity -= 1 if currentQuantity < 0 { currentQuantity = 0 }}//加else{ currentQuantity += 1}
更新數量UILabel
quantityLabel.text = "\(currentQuantity)"
計算金額
countPrice()
實作 func countPrice()
利用屬性children取得ChooseTableViewController
self.children.forEach { viewController in guard let chooseTableViewController = viewController as? ChooseTableViewController else { return }}
避免殘留值,先將parent(ChooseViewController)的屬性金額歸零
//金額先歸零price = 0
取得children(ChooseTableViewController)的屬性currentSizeAttribute,確保使用者有選擇尺寸才做後續動作
guard let currentSizeAttribute = chooseTableViewController.currentSizeAttribute else { return }
取得children(ChooseTableViewController)的屬性currentIceAttribute,確保使用者有選擇冰塊才做後續動作
guard let currentIceAttribute = chooseTableViewController.currentIceAttribute else { return }
宣告變數currentPrice作為飲料品項的單價
//單價var currentPrice = 0
根據屬性取得單價
switch currentSizeAttribute {case .M: //熱飲只有M的價格 if currentIceAttribute == .hot { currentPrice = Int(chooseTableViewController.item.hotMPrice ?? 0) }else{ currentPrice = Int(chooseTableViewController.item.coldMPrice ?? 0) }case .L: currentPrice = Int(chooseTableViewController.item.coldLPrice ?? 0)}
計算總價格(若加珍珠則+5塊)
if chooseTableViewController.currentAddBubble { price = currentQuantity * currentPrice + 5 * currentQuantity}else{ price = currentQuantity * currentPrice}
將總價格更新到UILabel上
chooseTableViewController.priceLabel.text = "\(price)"
- 新增品項至購物車
點擊按鈕即可將該品項新增至購物車,若沒有選擇完整資訊會跳出Alert訊息
實作@IBAction func clickAddToCart
取得children(ChooseTableViewController)
self.children.forEach { viewController in guard let chooseTableViewController = viewController as? ChooseTableViewController else { return }}
檢查品項的相關屬性是否為空。
(ice:冰塊、sugar:甜度、size:尺寸、currentQuantity:數量、price:金額)
if let ice = chooseTableViewController.currentIceAttribute,let sugar = chooseTableViewController.currentSugarAttribute,let size = chooseTableViewController.currentSizeAttribute,currentQuantity != 0,price != 0{
新增至購物車,並且出提示訊息}else{ 有屬性未選擇,出錯誤訊息}
新增至購物車,並且出提示訊息
let cartData = Cart(ice: ice, sugar: sugar, size: size, bubble: chooseTableViewController.currentAddBubble ,quantity: currentQuantity,price: price, itemID: item.recordID , itemName: item.itemName)g_cartData.append(cartData)NetWorkController.shared.showAlert(title: "提醒", message: "已新增至購物車") { alert in present(alert, animated: true, completion: nil)}
有屬性未選擇,出錯誤訊息
NetWorkController.shared.showAlert(title: "提醒", message: "請輸入完整資訊") { alert in present(alert, animated: true, completion: nil)}
Cart Page:
單純顯示global變數g_cart的資料與總金額,但考慮到有可能上一秒想喝煮濃那提,下一秒又想喝別的,所以做了刪除功能。
在購物車這邊除了送出訂單以外沒有比較特別的邏輯,關於送出訂單程式將會在下篇文章說明。
- 計算總金額
在CartViewController宣告屬性total,負責紀錄總金額
在viewDidLoad()計算總金額
setTotalPrice()
歸零
total = 0
根據g_cartData的內容計算總金額
if !g_cartData.isEmpty { total = g_cartData.reduce(0){ $0 + $1.price }}
判斷不是空的才要計算
if !g_cartData.isEmpty { 加總程式 }
使用reduce加總
total = g_cartData.reduce(0){ $0 + $1.price }
若沒有使用過的人看到reduce可能會有些疑惑,讓我們看看以下的例子:
- 宣告一個常數number型別為Int的Array
- 宣告一個常數total為常數number使用reduce加總後的結果
- 印出常數total
在reduce的method中可以看到兩個參數:initialResult、nextPartialResult。initialResult:初始值。
nextPartialResult:為閉包,在此閉包中還有兩個參數:
1. nextPartialResult中的參數Result:每次加總後的結果
2. nextPartialResult中的參數Int:number中的每筆數字。
將游標移到initialResult的placeHolder上,並且按下enter。
將游標移到nextPartialResult的placeHolder上,並且按下enter。
將初始值(Result)設為0、且在 in 中讓partialResult 加上 Int(因為只有一行程式碼,所以不需要return)
可以得出total為33
寫法還可以更簡潔,讓我們把閉包的內容都刪掉
直接寫上$0(代表第一個參數) + $1(代表第二個參數),結果相同。
- 刪除購物車品項
在tableView實作swipe的功能,點擊button達成刪除目的。
這裡沒有特別的地方,因此不再贅述。swipe相關程式解說可以參考iOS| #23 | 使用TableView與Core Data實作ToDo App。
系列文章:
若內容有誤煩請指教,感謝收看。