【iOS App】模仿製作 iOS Clock App - 鬧鐘 Alarm

Patty
彼得潘的 Swift iOS App 開發教室
14 min readOct 8, 2021

--

這次練習不同頁面之間如何傳遞和回傳資料代理委託事件以及資料儲存讀取等。

實作功能和學習重點

  • 傳遞資料:IBSegueAction
  • 回傳資料:unwind segue 搭配 prepare
  • 代理委派:delegate
  • 偵測屬性變更的 property observer:didSet
  • 編碼:自訂型別轉成 Data、解碼:Data 轉成自訂型別
  • 資料儲存:存在 Containers 下的 Data 的 Documents Directory

demo

GitHub

回想複習

在之前實作世界時鐘的分頁時已經練習 table view,包含 cell 的 auto layout、編輯頁面時的刪除等,還不了解的話可以先跳回之前的文章再回想一次。

傳遞資料:IBSegueAction

步驟:頁面 A 傳資料至頁面 B (storyboard 拉 segue)

  • 起點頁面:由頁面 A 和頁面 B 之間的 segue 拉至頁面 A 定義 @IBSegueAction 的方法。
  • 終點頁面:宣告傳遞後接收的 property ( 也可以增加初始化 init,其中 init 內參數包含接收的 property )

鬧鐘分頁使用 IBSegueAction 場景

  • 鬧鐘列表傳至編輯頁面
  • 編輯頁面傳至的重複天數、標籤、提示聲等頁面。

遇到的問題

當 IBSegueAction 傳遞資料時中間遇到 navigation controller 該怎麼辦,拉哪段 segue 定義?

透過當鬧鐘列表在修改時跳到編輯頁面,將第一頁選擇的 cell 鬧鐘傳遞資料至編輯頁面呈現詳細的鬧鐘資料,其中遇到 IBSegueAction 傳遞資料時中間遇到 navigation controller 如何處理的問題額外寫了一篇。

相同 segue 依其他條件判斷該如何傳遞資料

由於編輯按鈕和新增按鈕都是連至編輯詳細頁面,可以透過是否有選取 row (tableView.indexPathForSelectedRow)來辨別:如果有選取 row 表示要修改則會將 alarm 傳至編輯頁面,沒有選取 row 表示要新增則不會將 alarm 傳過去,所以下一頁的 alarm 為 nil。

@IBSegueAction func editAlarm(_ coder: NSCoder, sender: Any?, segueIdentifier: String?) -> EditAlarmTableViewController? {
guard let controller = EditAlarmTableViewController(coder: coder) else { return nil }
controller.delegate = self
// 修改鬧鐘,傳目前鬧鐘資訊至下頁
guard let row = tableView.indexPathForSelectedRow?.row else { return controller }
controller.alarm = alarmList[row]
return controller
}

初始化controller參數包含要傳遞的資料更方便

下一頁 controller 接收傳遞資料的 property,可以設定在初始化的參數當中,方便將傳遞的資料時直接回傳於 controller 的參數,例如編輯頁面傳遞資料至編輯鬧鐘標籤頁面。

// EditAlarmTableViewController.swift
@IBSegueAction func passLabelName(_ coder: NSCoder, sender: Any?, segueIdentifier: String?) -> EditAlarmLabelTableViewController? {
let alarmLabel = alarm?.alarmLabel ?? "Alarm"
return EditAlarmLabelTableViewController(coder: coder, alarmLabel: alarmLabel)
}
// EditAlarmLabelTableViewController.swift
var alarmName: String
init?(coder: NSCoder, alarmLabel: String){
self.alarmName = alarmLabel
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

回傳資料:返回前頁時一併回傳資料
如果回傳資料時也會返回前頁則可以考慮使用 unwind segue

鬧鐘分頁使用到 unwind segue 場景

  • 編輯頁面的儲存將鬧鐘資料回傳給鬧鐘列表
  • 選擇重複日、編輯鬧鐘標籤名稱、選擇提示聲將選擇結果透過導覽列的返回將結果回傳至編輯頁面

步驟:由頁面 B 返回且回傳資料至頁面 A

  • 在頁面 A 定義 unwind segue 的 function
  • 在頁面 B 的返回 UI 元件拉 segue 至 exit
    選擇上述定義的 unwind segue 且標記 segue id 用以使用時辨識
  • 返回前頁時,先執行 prepare 再執行 unwind segue,所以也可以搭配 prepare 做傳遞資料的動作,不一定需要 prepare。

詳細的 unwind segue 設定步驟可以參考 peter 文章

其中做 unwind segue 練習時遇到的問題

當返回 UI 元件是導覽列的 back 返回按鈕而直接拉 segue 至 exit 無作用該怎麼辦?

在選擇重複日、編輯鬧鐘標籤名稱、選擇提示聲將選擇結果透過導覽列的返回回傳至編輯頁面將會遇到這個問題。這裡我有額外寫一篇文章,大致流程相同差別在於由 controller 拉 segue 至 exit,然後在覆寫 viewWillDisappear 當中 isMovingFromParent 的情況下透過 performSegue 執行 unwind segue,細節再參考以下文章。

代理委派:delegate

鬧鐘分頁使用到 delegate 場景

  • 編輯頁面不論是透過取消按鈕返回前頁或是向下滑返回前頁時,鬧鐘列表的 UI 狀態是不可編輯
  • 從鬧鐘列表的 cell 當中切換 switch 開關,更新鬧鐘資料的是否啟用的 property 內容 。

步驟:B 委派 A 做事

頁面 B

  • 設定 protocol 和宣告 protocol 當中的 function
  • 宣告 delegate 和呼叫 delegate 的 function

頁面 A

  • 遵從 protocol 和定義 protocol 當中的 function
  • 設定代理人 delegate:通常是自己

cell 的 delegate

cell 的 delegate 設定內容可以參考以下這篇

其中,練習時遇到的問題

如何偵測切換分頁 present modally 向下滑

需要知道向下滑時會觸發的 function 中才可以呼叫 delegate 的 function 使前頁的 UI 畫面更新。我額外將切換分頁以 present modally 的方式在向下滑動會觸發的 function 做整理文章,有興趣的可以參考。

偵測屬性變更的 property observer:didSet

使用的時機:想要將鬧鐘列表依鬧鐘時間排序

原來不需要特別將鬧鐘的 cell 重新排列,只需要對資料內容做設定。概念是透過偵測屬性變更的 property observer - didSet,只要當資料內容變動時不論新增、修改或刪除時會觸發排序,就可以讓資料內容永遠呈現有序狀態,自然而然鬧鐘列表就會依鬧鐘的時間排序。

var alarmList = [Alarm]() {
didSet {
alarmList = alarmList.sorted { $0.alarmTime < $1.alarmTime }
}
}

編碼:自訂型別轉成 Data、解碼:Data 轉成自訂型別

  • 自訂型別需要遵從 Codable 的 protocol:則可編碼、解碼
    其中的 property 也需要遵從 Codable
// 編碼
let encoder = JSONEncoder()
let data = try? encoder.encode(alarms)
// 解碼
let decoder = JSONDecoder()
return try? decoder.decode([Self].self, from: data)

資料儲存:存在 Containers 下的 Data 的 Documents Directory

// 存檔
let url = documentsDirectory.appendingPathComponent("alarm") // 路徑
try? data?.write(to: url) // 寫入
// 讀取
let url = documentsDirectory.appendingPathComponent("alarm")
guard let data = try? Data(contentsOf: url) else { return nil }

綜合自訂型別解碼編碼和資料儲存

Alarm.swift

    static func saveAlarm(_ alarms: [Alarm]) {
// 編碼
let encoder = JSONEncoder()
let data = try? encoder.encode(alarms)
// 存檔
let url = documentsDirectory.appendingPathComponent("alarm") // 路徑
try? data?.write(to: url) // 寫入
}

// 讀取 documentDirectory 再解碼成自訂型別
static func loadAlarms() -> [Self]? { // [Self]: Self (大寫的 S) 代表型別 Alarm
// 讀取
let url = documentsDirectory.appendingPathComponent("alarm")
guard let data = try? Data(contentsOf: url) else { return nil }
// 解碼
let decoder = JSONDecoder()
return try? decoder.decode([Self].self, from: data)
}

AlarmTableViewController.swift

// 資料內容有變動時則儲存
var alarmList = [Alarm]() {
didSet {
Alarm.saveAlarm(alarmList)
}
}
// 鬧鐘分頁的開始畫面呼叫讀取鬧鐘內容
override func viewDidLoad() {
super.viewDidLoad()
if let alarmList = Alarm.loadAlarms(){
self.alarmList = alarmList
}
}

心得

這次大大練習許多有關資料傳遞的方法,透過大量練習更加了解,在過程中不斷以 print 驗證切換頁面經過的 function、目前的資料內容以及是否有如期傳遞資料。

不過當學習更多或是瞭解更清楚,才發現有很多情境是可以變通的,可以透過很多不一樣的方式達到相同效果。像是編輯頁面呼叫 delegate 的 function editAlarmTableViewControllerDidCancel 使前一頁 UI 變成不可編輯狀態,其實不一定要在 present modally 向下滑時觸發的 function 呼叫,也可以覆寫 viewWillDisappear 然後呼叫。

另外,在練習過程中總是有個念頭,覺得其實大致相同、有做出功能就可以,但還是會很猶豫究竟要不要達到跟內建 App 一樣同樣的操作就會有同樣的效果?還是將就好?最後,我仍然堅持要做到相同,想法其實很簡單

如果我只是做大致相同,那表示我還在原地踏步,因為我現階段做不出來,所以才只到這。

所以後來要求自己盡可能做到一樣,一部份也是逼自己學習、見識更廣,做不到的部分可以透過上網查詢找資料,想方法套用做出相同的效果。像這次重複天、標籤、提示音頁面透過導覽列返回按鈕回傳資料,其實我也可以透過選擇 cell 完之後直接返回且回傳。的確,如果我只是想要回傳資料,兩個方式都可以,但換個想法如果我做不到原本內建時鐘 App 的方式,其實是我做不出來,而不是我不想跟他一樣。既然現階段還在學習就應該儘量花心力找到問題的答案或方法,而不是只要求有就好或是忽略、避開而選擇其他方式。

--

--