昨天,我們的 App 終於活了起來!可以從資料庫讀取、刪除鬧鐘,列表也能響應我們的操作。但它還缺少一個最關鍵的功能:新增鬧鐘。我們的「+」按鈕只會打開一個空白的頁面,今天,我們就要把 AddAlarmViewController
打造出來。
困惑點:如何地打造一個能同時處理「新增鬧鐘」和「編輯鬧鐘」兩種的頁面。這兩種模式 UI 大同小異,但邏輯不同,該如何用同一份程式碼來處理?
AI 協作
我需要建立一個新增/編輯鬧鐘的頁面。它包含一個時間選擇器和一個設定列表(重複、標籤等)。請問,用 UITableView 來做這個設定列表是個好主意嗎?另外,對於在同一個 ViewController 中處理『新增』和『編輯』兩種模式,有沒有什麼推薦的設計模式?請簡單說明且不需要給程式碼
AI 的回答:
您好,這是一個很常見的 iOS 應用程式設計問題。針對您的需求,以下是我的專業建議:
使用 UITableView 來製作設定列表
使用 UITableView 是個非常好的主意。 這不僅是 iOS 開發中的標準做法,也提供了許多優勢:
- 使用者介面一致性 (UI Consistency): 幾乎所有 Apple 內建的應用程式,如「時鐘」、「聯絡資訊」、「設定」等,都使用 UITableView 來顯示這類階層式或靜態的設定列表。這讓使用者感到熟悉,降低了學習成本。
- 彈性與可擴充性 (Flexibility & Scalability): 您可以輕鬆地增加、刪除或重新排序列表項目(如:新增「鈴聲」選項)。每個 cell 都可以包含不同的 UI 元件(如:UILabel、UISwitch、UIButton),滿足各種設定需求。
- 效能優化 (Performance Optimization): UITableView 透過 cell 重用機制 (Cell Reuse Mechanism),只渲染目前可見的 cell。這對於包含大量資料的列表非常有效,儘管在您的「鬧鐘設定」頁面中資料量不大,但這種高效的架構設計仍然是最佳實踐。
- 靜態與動態內容的完美結合: 像「鬧鐘」這種固定數量的設定項目,非常適合使用 Static Table View。您不需要寫任何 delegate 或 data source 的程式碼,只需在 Storyboard 或 Interface Builder 中直接拖曳和配置 cell,大大簡化了開發流程。
處理「新增」和「編輯」模式的設計模式
在同一個 ViewController 中處理兩種模式(新增與編輯)是一種高效且推薦的設計。您可以使用以下兩種常見的設計模式:
1. 單一模型 (Single Model)
這是最簡單、最直接的方法。當您從上一個頁面導航到這個「鬧鐘設定」頁面時,可以傳遞一個參數來區分當前模式。
- 新增模式: 不傳入任何鬧鐘資料,ViewController 內部會建立一個新的、空的鬧鐘物件。
- 編輯模式: 傳入一個現有的鬧鐘物件。ViewController 使用這個物件的資料來填充所有 UI 元件(如:時間選擇器、重複選項等)。
簡單來說,您只需要在
viewDidLoad
或viewWillAppear
方法中,根據傳入的鬧鐘物件是否為空,來決定是初始化一個新鬧鐘還是載入現有資料。2. 狀態模式 (State Pattern)
雖然對於這種簡單的場景可能有點過度設計,但它是一種更強大、更具可擴展性的設計模式。您可以定義一個
Mode
列舉 (Enum),例如enum Mode { case add, edit(Alarm) }
。
- 新增模式: 設定 ViewController 的
mode
屬性為.add
。- 編輯模式: 設定
mode
屬性為.edit(existingAlarm)
。這種方法的優點在於,所有與模式相關的邏輯都集中在一個地方,使得程式碼更清晰。例如,您可以根據
mode
的值來決定導航列的標題("新增鬧鐘" 或 "編輯鬧鐘")。
LabelTextFieldCell
。AddAlarmViewController.xib
中,放置好 UIDatePicker
和 UITableView
。AddAlarmViewController.swift
中,加入所有必要的屬性(包含關鍵的 alarmToEdit
),並完成 UI 的基礎設定。UITableViewDataSource
:讓設定列表能根據不同模式(新增/編輯),顯示出對應的選項。AlarmData
物件傳入編輯頁的數據流。UITextFieldDelegate
讓標籤輸入框能正常工作。saveTapped
方法,根據是新增還是編輯模式,將資料正確地寫入 Realm 資料庫。在設定選項中,我們需要一個左邊是標題、右邊是輸入框的 Cell 來讓使用者輸入鬧鐘標籤。
在 View/Cells
資料夾上右鍵,建立一個新的 Cocoa Touch Class。
LabelTextFieldCell
UITableViewCell
設計 UI:打開 LabelTextFieldCell.xib
。
從物件庫 (+
) 拖曳一個 UILabel
到畫布左側,再拖曳一個 UITextField
到右側。
設定 UILabel
的約束:左邊距 (Leading) 設為 20
,並設定垂直置中 (Vertically Center in Container)。
設定 UITextField
的約束:
UILabel
的水平間距 (Horizontal Spacing) 設為 8
。16
。在屬性檢查器中,將 UITextField
的 Placeholder 設為「鬧鐘」,Border Style 設為無。
連接 IBOutlet:使用助理編輯器,將 UILabel
連接為 lbTitle
,UITextField
連接為 txfTitle
。
加入 Identifier: 在 LabelTextFieldCell.swift
中加入靜態 identifier
。
檔案:LabelTextFieldCell.swift
class LabelTextFieldCell: UITableViewCell {
static let identifier = "LabelTextFieldCell"
@IBOutlet weak var lbTitle: UILabel!
@IBOutlet weak var txfTitle: UITextField!
//...
}
這個頁面的結構很簡單:上方是一個時間選擇器,下方是一個設定選項的列表。
Controller/AddAlarmViewController.xib
。+
) 拖曳一個 Date Picker 到畫布頂部。
0
。+
) 拖曳一個 Table View 到 Date Picker 的下方。
0
,並設定其頂部 (Top) 與 Date Picker 的底部 (Bottom) 間距為 0
。dpkTime
,Table View 連接為 tbvTime
。現在我們來為這個頁面注入靈魂。我打開 AddAlarmViewController.swift
,加入了所有需要的屬性,並完成了 setupUI()
方法。
檔案:AddAlarmViewController.swift
import UIKit
import RealmSwift
// (此 protocol 已在Day13定義過)
protocol AddAlarmViewControllerDelegate: AnyObject {
func didSaveAlarm()
func didDeleteAlarm(alarm: AlarmData)
}
class AddAlarmViewController: UIViewController {
// MARK: - IBOutlet
@IBOutlet weak var dpkTime: UIDatePicker!
@IBOutlet weak var tbvTime: UITableView!
// MARK: - Property
var alarmToEdit: AlarmData? // 如果是編輯模式,這裡會有值
weak var delegate: AddAlarmViewControllerDelegate?
var repeatDays: [Bool] = Array(repeating: false, count: 7)
var selectedSound: String = "預設"
var alarmName: String = ""
var isSnoozeEnabled: Bool = true
// MARK: - LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
// 如果是編輯模式,則填入現有鬧鐘資料
if let alarm = alarmToEdit {
populateFields(with: alarm)
}
}
// MARK: - UI Setting
func setupUI() {
// 根據是新增還是編輯模式設定不同的標題和按鈕
if alarmToEdit == nil {
// 新增模式
title = "加入鬧鐘"
} else {
// 編輯模式
title = "編輯鬧鐘"
}
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "取消",
style: .plain,
target: self,
action: #selector(cancelTapped)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "儲存",
style: .plain,
target: self,
action: #selector(saveTapped)
)
// 設定時間選擇器
dpkTime.datePickerMode = .time
dpkTime.preferredDatePickerStyle = .wheels
dpkTime.locale = Locale(identifier: "en_GB") // 使用24小時制
// 設定表格視圖
tbvTime.dataSource = self
tbvTime.delegate = self
tbvTime.isScrollEnabled = false // 選項不多,固定即可
// 註冊自定義 Cell
tbvTime.register(UINib(nibName: LabelTextFieldCell.identifier, bundle: nil), forCellReuseIdentifier: LabelTextFieldCell.identifier)
}
// MARK: - IBAction
@objc func cancelTapped() {
self.dismiss(animated: true, completion: nil)
}
@objc func saveTapped() {
// 儲存邏輯將在步驟七實現
}
@objc func snoozeSwitchChanged(_ sender: UISwitch) {
self.isSnoozeEnabled = sender.isOn
}
}
我透過 extension
讓 AddAlarmViewController
遵守 UITableViewDataSource
和 UITableViewDelegate
,並在其中實作了顯示所有設定選項的邏輯。特別的是,我利用 alarmToEdit == nil
來判斷是否要顯示第二個 section,也就是「刪除鬧鐘」按鈕。
檔案:AddAlarmViewController.swift
extension AddAlarmViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
// 編輯模式下多一個「刪除」區塊
return alarmToEdit == nil ? 1 : 2
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return 4 // 選項區塊:重複、標籤、提示聲、稍後提醒
} else {
return 1 // 刪除區塊
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if indexPath.section == 0 {
// 使用 switch 處理不同的選項行
switch indexPath.row {
case 0: // 重複
let cell = UITableViewCell(style: .value1, reuseIdentifier: "DefaultCell")
cell.textLabel?.text = "重複"
cell.accessoryType = .disclosureIndicator
// 此處的 detailTextLabel 顯示邏輯較複雜,暫時留空,將在第六章實現
cell.detailTextLabel?.text = "永不"
return cell
case 1: // 標籤
let cell = tableView.dequeueReusableCell(withIdentifier: LabelTextFieldCell.identifier, for: indexPath) as! LabelTextFieldCell
cell.lbTitle.text = "標籤"
cell.txfTitle.placeholder = "鬧鐘"
// cell.txfTitle.delegate = self // 將在步驟六設定
return cell
case 2: // 提示聲
let cell = UITableViewCell(style: .value1, reuseIdentifier: "DefaultCell")
cell.textLabel?.text = "提示聲"
cell.accessoryType = .disclosureIndicator
cell.detailTextLabel?.text = selectedSound
return cell
case 3: // 稍後提醒
let cell = UITableViewCell(style: .default, reuseIdentifier: "DefaultCell")
cell.textLabel?.text = "稍後提醒"
let snoozeSwitch = UISwitch()
snoozeSwitch.isOn = self.isSnoozeEnabled
snoozeSwitch.addTarget(self, action: #selector(snoozeSwitchChanged(_:)), for: .valueChanged)
cell.accessoryView = snoozeSwitch
return cell
default:
return UITableViewCell()
}
} else {
// 刪除按鈕
let cell = UITableViewCell(style: .default, reuseIdentifier: "DefaultCell")
cell.textLabel?.text = "刪除鬧鐘"
cell.textLabel?.textColor = .red
cell.textLabel?.textAlignment = .center
return cell
}
}
}
為了處理「編輯模式」,我先在 AddAlarmViewController
中加入了 populateFields(with:)
方法,它負責將傳入的 AlarmData
物件內容,填入到對應的 UI 元件上。
檔案:AddAlarmViewController.swift
// 請在 class 中補上這個輔助方法
func formatStringToDate(_ timeString: String) -> Date? {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.date(from: timeString)
}
// 修改 populateFields 方法
func populateFields(with alarm: AlarmData) {
// 1. 將字串格式的時間轉換回 Date 物件並設定到時間選擇器
if let date = formatStringToDate(alarm.alarmTime) {
dpkTime.setDate(date, animated: false)
}
// 2. 設定各項屬性
self.alarmName = alarm.name
self.repeatDays = Array(alarm.repeatDays)
self.selectedSound = alarm.sound
self.isSnoozeEnabled = alarm.isSnoozeEnabled
}
接著,我回到 MainViewController.swift
,實作 tableView(_:didSelectRowAt:)
。當使用者點擊任一鬧鐘時,這個方法會將該鬧鐘物件,透過 alarmToEdit
屬性傳遞給 AddAlarmViewController
。
檔案:MainViewController.swift
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) // 取消選中高亮
guard indexPath.section == 1 else { return } // 只處理鬧鐘列表的點擊
let selectedAlarm = alarms[indexPath.row]
let addAlarmVC = AddAlarmViewController(nibName: "AddAlarmViewController", bundle: nil)
addAlarmVC.alarmToEdit = selectedAlarm // 【關鍵】將要編輯的鬧鐘物件傳過去
addAlarmVC.delegate = self
let navController = UINavigationController(rootViewController: addAlarmVC)
present(navController, animated: true, completion: nil)
}
為了讓「標籤」輸入框能正常工作,我讓 AddAlarmViewController
遵守了 UITextFieldDelegate
,並實作了兩個代理方法,來即時更新 alarmName
屬性以及在使用者點擊 Return 鍵時收起鍵盤。
檔案:AddAlarmViewController.swift
// 在 cellForRowAt 的 case 1 裡加上這行:
cell.txfTitle.delegate = self
// 在檔案底部加入 extension
// MARK: - 文字輸入框代理
extension AddAlarmViewController: UITextFieldDelegate {
func textFieldDidChangeSelection(_ textField: UITextField) {
// 當文字改變時,即時更新 alarmName 屬性
self.alarmName = textField.text ?? ""
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
// 點擊 Return 鍵時,收起鍵盤
textField.resignFirstResponder()
return true
}
}
我要讓「儲存」按鈕真正地將資料寫入資料庫。
我分別建立了 saveNewAlarm()
和 updateAlarm(_:)
兩個方法,它們負責處理新增和更新的資料庫寫入邏輯。最後,在 saveTapped()
方法中,透過檢查 alarmToEdit 是否存在,來決定到底該呼叫哪個方法。
檔案:AddAlarmViewController.swift
// 在 AddAlarmViewController.swift 中加入
// MARK: - 工具方法
/// 將 Date 物件轉換成 "HH:mm" 格式的字串
func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
// MARK: - Function
func saveNewAlarm() {
let realm = try! Realm()
let newAlarm = AlarmData(
alarmTime: formatDate(dpkTime.date),
creatTime: String(Date().timeIntervalSince1970),
name: self.alarmName,
repeatDays: self.repeatDays,
sound: self.selectedSound
)
newAlarm.isSnoozeEnabled = self.isSnoozeEnabled
try! realm.write {
realm.add(newAlarm)
}
delegate?.didSaveAlarm() // 通知主畫面刷新
self.dismiss(animated: true, completion: nil)
}
func updateAlarm(_ alarm: AlarmData) {
let realm = try! Realm()
try! realm.write {
alarm.alarmTime = formatDate(dpkTime.date)
alarm.name = self.alarmName
alarm.repeatDays.removeAll()
alarm.repeatDays.append(objectsIn: self.repeatDays)
alarm.sound = self.selectedSound
alarm.isSnoozeEnabled = self.isSnoozeEnabled
}
delegate?.didSaveAlarm() // 通知主畫面刷新
self.dismiss(animated: true, completion: nil)
}
// 修改 saveTapped 方法
@objc func saveTapped() {
if let alarm = alarmToEdit {
updateAlarm(alarm) // 編輯模式
} else {
saveNewAlarm() // 新增模式
}
}
運行 App,現在鬧鐘最核心的數據流已經完全打通了!
明天,我們將把今天暫時留白的「重複」和「提示聲」這兩個子功能的頁面完成,讓我們的鬧鐘 App 更加完整。