iOS| #25 | 約翰紅茶訂餐App 2.0–Part.2 點餐與購物車篇

Tommy
彼得潘的 Swift iOS / Flutter App 開發教室

--

不久前已經實作過訂飲料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可能會有些疑惑,讓我們看看以下的例子:

  1. 宣告一個常數number型別為Int的Array
  2. 宣告一個常數total為常數number使用reduce加總後的結果
  3. 印出常數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

若內容有誤煩請指教,感謝收看。

--

--