【iOS】#7 訂飲料APP|Part.1 串接 Airtable API — GET、飲品分類導航欄、自動循環輪播 banner

前情提要 — 可不可飲料訂購APP為系列文章,初步規劃分為四篇文章介紹以下四大頁面及功能:

・註冊登入頁面:使用者註冊登入及訪客登入|Firebase 身份驗證
・Menu菜單頁面:串接 Airtable API 呈現飲料菜單|GET
・飲料訂購頁面:新增飲料訂單|POST
・訂購清單頁面:編輯、刪除訂單|PATCH、DELETE

本篇Menu 菜單頁面

🌟 本篇重點功能

  1. 串接 Airtable API 呈現飲料菜單,使用 RESTful API — GET
  2. 飲品分類導航欄,使用 UITableViewHeaderFooterView
  3. 自動循環輪播 banner,使用 UICollectionView & UIPageControlTimerProgress(iOS 17) or Timer
成果展示

功能介紹

① 串接 Airtable API 呈現飲料菜單,使用 RESTful API — GET

1. 建立 Airtable 飲料菜單

利用 Airtable 來製作 APP 連結的後台資料,將畫面需要顯示的資料,設計成相對應的欄位(包含飲料的類別、名字、價格、圖片、介紹)

2. 取得 Airtable 菜單的 JSON 資料

Airtable Developers Web API 文件頁面,可以看到自己製作的 table 名稱,點進去即可看到 API 相關設定

AUTHENTICATION

在 AUTHENTICATION 頁面生成 API TOKEN,作為之後與 Airtable 通信的密鑰,拿到後記得把自己的 API TOKEN 記錄起來。

AUTHENTICATION

讀取 — GET

找到 GET Request 的說明頁面,右邊可以看到 API 網址、Header 要帶的資訊、Response 的 Json 格式

GET Request

POSTMAN

可以用 Postman 先檢查看看 API 是否運作正常

POSTMAN

3. 根據 Json 格式設定相對應 Struct

為了要解析 Json,需要根據 Json 的資料結構寫 Struct 讓程式去解析,可以使用 Quicktype,在左邊貼上 Response 的 Json 資料結構,網站會自動生成相對應的 Struct

參考網站生成的 Struct,根據 APP 所需定義自己的 DrinkResponse

import Foundation

// MARK: - DrinkResponse
struct DrinkResponse: Decodable {
let records: [DrinkRecord]
}

// MARK: - DrinkRecord
struct DrinkRecord: Decodable {
let fields: DrinkFields
}

// MARK: - DrinkFields
struct DrinkFields: Decodable {
let name: String
let description: String
let medium: Int
let large: Int
let image: [DrinkImage]
let category: Category
}

enum Category: String, Decodable {
case classic = "單品茶"
case seasonal = "季節限定"
case milk = "歐蕾"
case mix = "調茶"
case cream = "雲蓋"
}

// MARK: - DrinkImage
struct DrinkImage: Decodable {
let url: URL
}

(在 Struct 中增加了飲品類別的 Enum 方便之後切換 Menu 使用)

4. URLSession 抓取資料

MenuViewController — fetchDrinkData

將網路請求功能集中於 MenuViewController,建立 static 變數 shared 方便之後在任何地方都能呼叫自己(其他頁面的網路請求功能在系列文章會陸續加入)

由於URL前段內容均相同,可建立常數 baseURL,儲存 API 讀取的基本網址,後面可以接 path & queryItem

class MenuViewController: UIViewController {

static let shared = MenuViewController()

private let baseURL = URL(string: "https://api.airtable.com/v0/appxrciNhGMQw3sSj")!
private let apiKey = "your_api_key_here"

var drinks = [DrinkRecord]()
private var drinksOfselectedCategory = [DrinkRecord]()

override func viewDidLoad() {
super.viewDidLoad()

fetchDrinkData()
}

// MARK: - GET Drink
func fetchDrinkData() {
let drinkURL = baseURL.appendingPathComponent("Drink")
var request = URLRequest(url: drinkURL)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, response, error in
guard let data else { return }
do {
// 抓取資料時(GET),建立Decoder進行解碼
let decoder = JSONDecoder()
let drink = try decoder.decode(DrinkResponse.self, from: data)
// 將抓到的資料存入變數drinks
self.drinks = drink.records
// 將飲品類別第一頁「季節限定」的飲品存入變數drinksOfselectedCategory
for drink in self.drinks {
if drink.fields.category == Category.seasonal {
self.drinksOfselectedCategory.append(drink)
}
}
// 在主執行緒更新畫面
DispatchQueue.main.async {
self.menuTableView.reloadData()
}
} catch {
print(error)
}
}.resume()
}

}

(補充:URLSession 執行於背景執行緒,更新 UI 畫面需使用DispatchQueue.main至主執行緒執行)

5. 呈現菜單資料

MenuViewController — tableView

extension MenuViewController: UITableViewDelegate, UITableViewDataSource {

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = menuTableView.dequeueReusableCell(withIdentifier: "drinkCell", for: indexPath) as! DrinkCell
let drink = drinksOfselectedCategory[indexPath.row]

cell.drinkName.text = drink.fields.name
cell.drinkDescription.text = drink.fields.description
cell.drinkPrice.text = "中:$\(drink.fields.medium) / 大:$\(drink.fields.large)"
// 使用 Kingfisher 抓圖
cell.drinkImageView.kf.setImage(with: drink.fields.image.first?.url)
cell.selectionStyle = .none

return cell
}

}

② 飲品分類導航欄

點選飲品分類自動切換顯示不同類別的飲品

飲品分類導航欄

1. 客製 UITableViewHeaderFooterView

MenuHeaderView

AutoLayout 畫面

import UIKit

class MenuHeaderView: UITableViewHeaderFooterView {

let categoryButtonStackView = UIStackView()
let seasonalButton = UIButton()
let classicButton = UIButton()
let mixButton = UIButton()
let creamButton = UIButton()
let milkButton = UIButton()
let underline = UIView()
let underlineBackground = UIView()

override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: "menuHeader")

configUI()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func configUI() {
// 設置contentView
contentView.backgroundColor = .darkPrimary
contentView.addSubview(categoryButtonStackView)
contentView.addSubview(underline)
contentView.addSubview(underlineBackground)

// 設置StackView
categoryButtonStackView.axis = .horizontal
categoryButtonStackView.alignment = .fill
categoryButtonStackView.distribution = .fillEqually
categoryButtonStackView.spacing = 0
categoryButtonStackView.snp.makeConstraints { make in
make.left.right.equalToSuperview().inset(8)
make.top.equalToSuperview()
make.height.equalTo(46)
}

// 添加 Button 至 StackView
categoryButtonStackView.addArrangedSubview(seasonalButton)
categoryButtonStackView.addArrangedSubview(classicButton)
categoryButtonStackView.addArrangedSubview(mixButton)
categoryButtonStackView.addArrangedSubview(creamButton)
categoryButtonStackView.addArrangedSubview(milkButton)

// 設置 stackView 中所有 Button
for case let button as UIButton in categoryButtonStackView.arrangedSubviews {
button.titleLabel?.font = UIFont.systemFont(ofSize: 15)
// 設置未選取的 Button 文字顏色
button.setTitleColor(.unselected, for: .normal)
// 設置已選取的 Button 文字顏色
button.setTitleColor(.white, for: .selected)
// 綁定按鈕功能
button.addTarget(self, action: #selector(changeCategory), for: .touchUpInside)
}

// 將飲品類別第一頁「季節限定」Button 狀態設為已選取
seasonalButton.isSelected = true

//設置 Button title
seasonalButton.setTitle(Category.seasonal.rawValue, for: .normal)
classicButton.setTitle(Category.classic.rawValue, for: .normal)
mixButton.setTitle(Category.mix.rawValue, for: .normal)
creamButton.setTitle(Category.cream.rawValue, for: .normal)
milkButton.setTitle(Category.milk.rawValue, for: .normal)

// 設置選取時顯示的下底線
underline.backgroundColor = .secondary
underline.snp.makeConstraints { make in
make.bottom.equalTo(categoryButtonStackView.snp.bottom)
make.width.equalTo(seasonalButton).offset(16)
make.centerX.equalTo(seasonalButton)
make.height.equalTo(2)
}

// 設置下底線背景
underlineBackground.backgroundColor = .unselected
underlineBackground.snp.makeConstraints { make in
make.bottom.equalTo(categoryButtonStackView.snp.bottom)
make.centerX.equalToSuperview()
make.width.equalToSuperview().offset(8)
make.height.equalTo(0.5)
}
}

}

在 menuTableView 中 return 做好的 MenuHeaderView

extension MenuViewController: UITableViewDelegate, UITableViewDataSource {

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let headerView = menuTableView.dequeueReusableHeaderFooterView(withIdentifier: "menuHeader") as! MenuHeaderView
headerView.delegate = self
return headerView
}

func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 46
}

}

2. 切換飲品類別動畫

點選不同飲品類別時,將下底線移動至該類別位置,並添加動畫效果

class MenuHeaderView: UITableViewHeaderFooterView {

func setUnderlinePositon(button: UIButton) {

underline.snp.remakeConstraints { make in
make.bottom.equalTo(categoryButtonStackView.snp.bottom)
make.width.equalTo(seasonalButton).offset(16)
// 下底線 centerX 對齊至選取的 Button
make.centerX.equalTo(button)
make.height.equalTo(2)
}

// 添加動畫效果
UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) {
self.contentView.layoutIfNeeded()
}.startAnimation()

}

}

3. 指定 Button 的 delegate 對象為 MenuViewController,更換菜單內容

定義 protocol

代理對象需實做 changeMenuTo(category: String) 方法

建立 delegate 屬性

delegate 屬性宣告為 weak 避免循環引用,在按鈕綁定的 function 中呼叫 delegate 執行 changeMenuTo(category: String) 方法

import UIKit

protocol CategoryButtonDelegate: AnyObject {
func changeMenuTo(category: String)
}

class MenuHeaderView: UITableViewHeaderFooterView {

weak var delegate: CategoryButtonDelegate?

@objc func changeCategory(sender: UIButton) {

// 點選 Button 時呼叫 setUnderlinePositon 方法移動下底線位置
setUnderlinePositon(button: sender)

// 將所有 Button 文字設為未選取顏色
for case let button as UIButton in categoryButtonStackView.arrangedSubviews {
button.isSelected = false
}
// 選取的 Button 文字設為已選取顏色
sender.isSelected = !sender.isSelected

// 呼叫 delegate 執行 changeMenuTo 方法,傳入參數為選取的類別名稱
delegate?.changeMenuTo(category: (sender.titleLabel?.text)!)

}

}

MenuViewController — changeMenuTo(category: String)

MenuViewController 遵從 protocol,實作 changeMenuTo(category: String),更換菜單內容

extension MenuViewController: CategoryButtonDelegate {

func changeMenuTo(category: String) {

// 將原本的飲品Array清空
drinksOfselectedCategory.removeAll()

// Array加入選取類別的飲品
for drink in drinks {
if drink.fields.category.rawValue == category {
drinksOfselectedCategory.append(drink)
}
}

// 在主執行緒更新畫面
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.menuTableView.reloadData()
}

}

}

③自動循環輪播 banner

UIPageControlTimerProgress
Timer

1. 先製作一個普通的 banner (需手動滾動)

MenuViewController

AutoLayout 畫面:bannerCollectionView、bannerPageControl 、bannerImages

class MenuViewController: UIViewController {

let bannerCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
let bannerPageControl = UIPageControl()
var bannerImages = [UIImage]()

override func viewDidLoad() {
super.viewDidLoad()

setBannerImages()
configBannerCollectionView()
configBannerPageControl()
}

func setBannerImages() {
bannerImages.append(UIImage(named: "banner_0")!)
bannerImages.append(UIImage(named: "banner_1")!)
bannerImages.append(UIImage(named: "banner_2")!)
bannerImages.append(UIImage(named: "banner_3")!)
bannerImages.append(UIImage(named: "banner_4")!)
bannerImages.append(UIImage(named: "banner_5")!)
}

func configBannerCollectionView() {
bannerView.addSubview(bannerCollectionView)
bannerCollectionView.backgroundColor = .clear
bannerCollectionView.isPagingEnabled = true
bannerCollectionView.showsHorizontalScrollIndicator = false
bannerCollectionView.snp.makeConstraints { make in
make.top.equalToSuperview().inset(10)
make.left.right.equalToSuperview()
make.height.equalTo(230)
}
bannerCollectionView.delegate = self
bannerCollectionView.dataSource = self
bannerCollectionView.register(BannerImageCell.self, forCellWithReuseIdentifier: "bannerImageCell")
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0
bannerCollectionView.collectionViewLayout = layout
}

func configBannerPageControl() {
bannerView.addSubview(bannerPageControl)
bannerPageControl.numberOfPages = bannerImages.count
bannerPageControl.currentPage = 0
bannerPageControl.pageIndicatorTintColor = .unselected
bannerPageControl.currentPageIndicatorTintColor = .secondary
bannerPageControl.snp.makeConstraints { make in
make.top.equalTo(bannerCollectionView.snp.bottom).offset(6)
make.centerX.equalToSuperview()
make.width.equalTo(200)
make.height.equalTo(25)
make.bottom.equalToSuperview().inset(10)
}
}

}

extension MenuViewController: UICollectionViewDelegate, UICollectionViewDataSource {

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return bannerImages.count
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = bannerCollectionView.dequeueReusableCell(withReuseIdentifier: "bannerImageCell", for: indexPath) as! BannerImageCell
cell.bannerImageView.image = bannerImages[indexPath.row]
return cell
}

// 手勢滾動時pageControl換頁
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if scrollView == bannerCollectionView {
let pageNumber = scrollView.contentOffset.x / scrollView.bounds.width
bannerPageControl.currentPage = Int(pageNumber)
}
}

}

extension MenuViewController: UICollectionViewDelegateFlowLayout {

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return bannerCollectionView.bounds.size
}

}

BannerImageCell

AutoLayout 畫面

import UIKit

class BannerImageCell: UICollectionViewCell {

let bannerImageView = UIImageView()

override init(frame: CGRect) {
super.init(frame: frame)

contentView.addSubview(bannerImageView)
bannerImageView.contentMode = .scaleAspectFit
bannerImageView.clipsToBounds = true
bannerImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.center.equalToSuperview()
}
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

}

以上完成後,可以得到一般的 banner 效果(需手動滾動)

手動滾動

2. 加入 UIPageControlTimerProgress (iOS 17)

UIPageControlTimerProgress (iOS 17)

UIPageControlTimerProgress 可設定每隔幾秒觸發 pageControl 切換小圓點,加上 signPageControlValueChanged 功能,實現輪播效果

do re mi so 🪄

class MenuViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

// 設置UIPageControlTimerProgress,每3秒切換小圓點
let timerProgress = UIPageControlTimerProgress(preferredDuration: 3)

// 將它設為pageControl的progress
bannerPageControl.progress = timerProgress

// 輪播無限循環設為true
timerProgress.resetsToInitialPageAfterEnd = true

// 呼叫resumeTimer啟動timer
timerProgress.resumeTimer()

// 綁定功能signPageControlValueChanged:切換小圓點時將觸發
bannerPageControl.addTarget(self, action: #selector(signPageControlValueChanged), for: .valueChanged)
}

@objc func signPageControlValueChanged(_ sender: UIPageControl) {

// 切換小圓點時改變顯示的圖片
let indexPath = IndexPath(item: sender.currentPage, section: 0)
bannerCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}

}
自動循環輪播 (同時可手動滾動)

3. 使用 Timer

因 UIPageControlTimerProgress 要 iOS 17 才支援,我們用 Timer 也可以做出輪播效果

Timer

class MenuViewController: UIViewController {

var imageIndex = 0
var timer: Timer?

override func viewDidLoad() {
super.viewDidLoad()

// 設置Timer,每3秒觸發changeBanner功能
timer = Timer.scheduledTimer(timeInterval: 3, target: self, selector: #selector(changeBanner), userInfo: nil, repeats: true)
}

// MenuViewController釋放時移除timer
deinit {
timer?.invalidate()
}

@objc func changeBanner() {
imageIndex += 1
// index超出range時歸零
if imageIndex == bannerImages.count {
imageIndex = 0
}
let indexPath = IndexPath(item: self.imageIndex, section: 0)
bannerCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
bannerPageControl.currentPage = imageIndex
}

}

extension MenuViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 手動滾動時同步imageIndex
imageIndex = bannerPageControl.currentPage
}
}
自動循環輪播 (同時可手動滾動)

優化最後一張圖片循環至第一張時動畫效果

修改以下 function 內容

@objc func changeBanner() {
imageIndex += 1
var indexPath = IndexPath(item: self.imageIndex, section: 0)
bannerCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
bannerPageControl.currentPage = imageIndex

// 切換到最後一張banner時(假的第一張banner)
if imageIndex == (bannerImages.count - 1) {
bannerPageControl.currentPage = 0
// 0.5秒後(滾動動畫結束後)將最後一張偷偷換回第一張
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.imageIndex = 0
indexPath = IndexPath(item: self.imageIndex, section: 0)
self.bannerCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
}
}
}

func setBannerImages() {
bannerImages.append(UIImage(named: "banner_0")!)
bannerImages.append(UIImage(named: "banner_1")!)
bannerImages.append(UIImage(named: "banner_2")!)
bannerImages.append(UIImage(named: "banner_3")!)
bannerImages.append(UIImage(named: "banner_4")!)
bannerImages.append(UIImage(named: "banner_5")!)
// 在Array最後加入第一張圖片
bannerImages.append(UIImage(named: "banner_0")!)
}

func configBannerPageControl() {
// pageControl小圓點數量記得減一
bannerPageControl.numberOfPages = (bannerImages.count - 1)
}
優化最後一張圖片返回第一張動畫效果

--

--