昨天,我們成功地用假資料搭建了一個外觀精美的 UI。列表看起來有模有樣,但它目前的開關按了沒反應,資料都是寫死的,更沒有辦法新增或刪除鬧鐘。
困惑點:如何讓畫面和資料庫「同步」?當我刪除一筆資料時,我要如何「通知」UITableView
更新畫面?當鬧鐘 Cell 裡的開關被切換時,又要如何確保資料庫裡的狀態也同步更新?還有「新增鬧鐘」頁面儲存一個新鬧鐘後,這個列表要怎麼知道並顯示最新的項目?
AI 協作
我準備將 UITableView 和 Realm 資料庫連接起來。我有幾個問題:從 Realm 讀取所有資料的最佳實踐是什麼?當我刪除一筆 Realm 資料後,要如何最有效率地更新 UITableView?如何處理 Cell 內部元件(例如 UISwitch)的事件,並將其狀態寫回 Realm?請簡單說明且不需要給程式碼
AI 的回答:
從 Realm 資料庫連接
UITableView
是一個常見的 iOS 應用程式開發任務。以下是針對您所提出的問題,提供專業且簡潔的解答,這些方法都是基於最佳實踐。從 Realm 讀取所有資料的最佳實踐
從 Realm 讀取資料最有效率的方式是使用
Results
物件。Results
是一個自動更新的集合,它會即時反映資料庫中的任何變動。您不需要手動重新讀取資料,因為當資料變更時,Results
會自動更新。
刪除 Realm 資料後更新
UITableView
當您從 Realm 中刪除一筆資料時,最有效率的
UITableView
更新方式是利用Results
的自動更新特性,並結合UITableView
的deleteRows(at:with:)
方法。
- 首先,您需要一個
Results
物件來作為UITableView
的資料來源。- 當您刪除 Realm 資料時,
Results
會自動偵測到變動。- 此時,您可以在
UITableView
的deleteRows
方法中,傳入對應的IndexPath
,並指定一個動畫效果(例如.fade
),來刪除表格中的那一列。這種方法比重新載入整個表格 (
tableView.reloadData()
) 更加高效,因為它只更新受到影響的單元格,同時提供了更流暢的使用者體驗。
處理 Cell 內部元件事件並寫回 Realm
要處理
UITableViewCell
內部元件(如UISwitch
)的事件,並將其狀態寫回 Realm,最推薦的方式是使用 Delegate 模式。
- 在
UITableViewCell
中定義一個 Protocol:這個 Protocol 會定義一個方法,例如switchValueChanged(for:with:)
,用來將變更的狀態和對應的IndexPath
傳遞出去。- 在
UIViewController
中實現這個 Protocol:當您在cellForRowAt
方法中配置 Cell 時,將UIViewController
設定為 Cell 的 Delegate。- 在
UISwitch
的valueChanged
事件中調用 Delegate 方法:當UISwitch
的狀態改變時,觸發它的valueChanged
事件,並在事件處理函數中呼叫 Delegate 方法,將新的狀態和 Cell 的IndexPath
傳遞給UIViewController
。- 在 Delegate 方法中更新 Realm:
UIViewController
接收到 Delegate 訊息後,會根據傳入的IndexPath
找到對應的 Realm 物件,並在**寫入交易(write transaction)**中更新該物件的屬性。這種方法將
UITableViewCell
的邏輯與UIViewController
分離,遵循了單一職責原則,使程式碼更易於維護和擴展。
MainViewController
中引入 Realm,建立 setupRealm
和 loadAlarms
方法,並修改 DataSource
以使用真實數據。NavigationBar
上的「新增」與「編輯」按鈕,並建立好目標頁面 AddAlarmViewController
的空殼與溝通用的 Delegate
協議。我實作了 setupRealm()
和 loadAlarms()
方法,並在 viewDidLoad
中依序呼叫它們。loadAlarms()
會從 Realm 抓取所有 AlarmData
物件,並根據時間排序,最後通知 TableView
刷新。
檔案:MainViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
setupRealm() // 先初始化資料庫
setupTableView()
loadAlarms() // 再載入資料
}
// MARK: - 初始化設定
/// 設定 Realm 資料庫
private func setupRealm() {
do {
realm = try Realm()
} catch {
print("初始化 Realm 失敗: \(error)")
}
}
/// 從資料庫載入所有鬧鐘資料
func loadAlarms() {
// 從 realm 抓取所有 AlarmData 物件,並依照 alarmTime 欄位升序排序
alarms = realm.objects(AlarmData.self).sorted(byKeyPath: "alarmTime", ascending: true)
// 通知 TableView 畫面需要全面刷新
tbvAlarm.reloadData()
}
最後,我修改了 tableView(_:numberOfRowsInSection:)
,讓它回傳 alarms?.count ?? 0
,也就是真實的鬧鐘數量。
在:extension MainViewController: UITableViewDataSource, UITableViewDelegate
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return 1
} else {
// 不再回傳 3,而是回傳真實的鬧鐘數量
return alarms?.count ?? 0
}
}
為了讓鬧鐘資訊更一目了然,我寫了兩個輔助方法:
generateDisplayText(for:)
: 根據鬧鐘設定的重複日,產生如「鬧鐘, 每個平日」或「鬧鐘, 週一、週三」這樣的智慧提示文字。updateCellAppearance(_:isEnabled:)
: 根據鬧鐘是否啟用,將 Cell 內的文字顏色變為灰色或正常顏色。檔案:MainViewController.swift
// MARK: - Function
// ... 在 loadAlarms() 下方加入 ...
/// 生成顯示文字(鬧鐘名稱 + 重複資訊)
func generateDisplayText(for alarm: AlarmData) -> String {
let daysOfWeek = ["週日", "週一", "週二", "週三", "週四", "週五", "週六"]
let repeatDaysArray = Array(alarm.repeatDays)
let weekdaysTemplate = [false, true, true, true, true, true, false]
let weekendTemplate = [true, false, false, false, false, false, true]
let selectedDays = repeatDaysArray.enumerated()
.filter { $0.1 }
.map { daysOfWeek[$0.0] }
// 判斷重複模式
var repeatText = ""
if repeatDaysArray == weekdaysTemplate {
repeatText = "每個平日"
} else if repeatDaysArray == weekendTemplate {
repeatText = "每個週末"
} else if selectedDays.count == 7 {
repeatText = "每天"
} else if selectedDays.count > 0 {
repeatText = selectedDays.joined(separator: "、")
}
// 組合顯示文字
let alarmName = alarm.name.isEmpty ? "鬧鐘" : alarm.name
if repeatText.isEmpty {
return alarmName
} else {
return "\(alarmName), \(repeatText)"
}
}
/// 更新 Cell 的外觀(顏色)
func updateCellAppearance(_ cell: AlarmTableViewCell, isEnabled: Bool) {
cell.lbTime.textColor = isEnabled ? .label : .systemGray
cell.lbName.textColor = isEnabled ? .label : .systemGray
}
然後,在 tableView(_:cellForRowAt:)
中,我用這兩個新方法來設定 Cell 的內容,並為 UISwitch
加上了 addTarget
,讓它在狀態改變時能通知我。
檔案:MainViewController.swift
// In tableView(_:cellForRowAt:)
// ...
} else {
let cell = tableView.dequeueReusableCell(
withIdentifier: AlarmTableViewCell.identifier, for: indexPath
) as! AlarmTableViewCell
let alarm = alarms[indexPath.row]
// 【使用新的輔助方法來優化顯示】
cell.lbTime.text = alarm.alarmTime
cell.lbName.text = generateDisplayText(for: alarm)
cell.swAlarm.isOn = alarm.isEnabled
updateCellAppearance(cell, isEnabled: alarm.isEnabled)
// 為開關加上互動,我們將在後面實現它
cell.swAlarm.tag = indexPath.row
cell.swAlarm.addTarget(self, action: #selector(alarmSwitchChanged(_:)), for: .valueChanged)
return cell
}
一個只能看不能增刪的列表是沒有意義的。我需要加上「新增」和「編輯」按鈕。
我先快速建立了一個空的 AddAlarmViewController.swift
,並在檔案頂部定義好 AddAlarmViewControllerDelegate
協議,這是未來「新增/編輯頁」與「列表頁」溝通的合約。
檔案:AddAlarmViewController.swift
import UIKit
// 用於與主頁面溝通的代理協議
protocol AddAlarmViewControllerDelegate: AnyObject {
func didSaveAlarm()
func didDeleteAlarm(alarm: AlarmData)
}
class AddAlarmViewController: UIViewController {
// MARK: - IBOutlet
// MARK: - Property
var alarmToEdit: AlarmData? // 如果是編輯模式,這裡會有值
weak var delegate: AddAlarmViewControllerDelegate?
}
然後,回到 MainViewController.swift
,我撰寫了 setupNavigationBar()
方法來設定大標題、右邊的「+」按鈕和左邊的「編輯」按鈕。並實作了 @objc
方法 addTapped()
和 editTapped()
。
檔案:MainViewController.swift
// In viewDidLoad()
// ...
loadAlarms()
setupNavigationBar() // 新增此行
// MARK: - UI Setting
func setupNavigationBar() {
title = "鬧鐘"
navigationController?.navigationBar.prefersLargeTitles = true
// 右側新增按鈕
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addTapped)
)
// 左側編輯按鈕
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "編輯", style: .plain, target: self, action: #selector(editTapped)
)
}
// MARK: - IBAction
@objc func addTapped() {
let addAlarmVC = AddAlarmViewController(nibName: "AddAlarmViewController", bundle: nil)
addAlarmVC.delegate = self // 設定代理人為自己
let navController = UINavigationController(rootViewController: addAlarmVC)
present(navController, animated: true, completion: nil)
}
@objc func editTapped() {
isEditingMode.toggle()
tbvAlarm.setEditing(isEditingMode, animated: true)
navigationItem.leftBarButtonItem?.title = isEditingMode ? "完成" : "編輯"
}
最後一步,就是將所有互動的邏輯串連起來。
我讓 MainViewController
遵守剛剛建立的 AddAlarmViewControllerDelegate
協議。這樣,當未來 AddAlarmViewController
呼叫 didSaveAlarm()
時,這裡的 loadAlarms()
就會被觸發,列表也就會自動刷新!
檔案:MainViewController.swift
// MARK: - AddAlarmViewController 代理
extension MainViewController: AddAlarmViewControllerDelegate {
func didSaveAlarm() {
// 當收到儲存成功的通知時,重新從資料庫載入資料並刷新列表
loadAlarms()
}
func didDeleteAlarm(alarm: AlarmData) {
// 直接呼叫我們寫好的刪除方法
deleteAlarm(alarm)
}
}
接著,我實作了deleteAlarm(_:)
和alarmSwitchChanged(_:)
方法。
檔案:MainViewController.swift
// MARK: - Function
// ... 在 updateCellAppearance(...) 下方加入 ...
/// 刪除指定的鬧鐘
func deleteAlarm(_ alarm: AlarmData) {
do {
try realm.write {
realm.delete(alarm)
}
} catch {
print("刪除鬧鐘失敗: \(error)")
}
tbvAlarm.reloadData()
}
// MARK: - IBAction
// ... 在 editTapped() 下方加入 ...
/// 鬧鐘開關切換事件
@objc func alarmSwitchChanged(_ sender: UISwitch) {
let rowIndex = sender.tag
let alarmToUpdate = alarms[rowIndex]
let newStatus = sender.isOn
// 更新資料庫中的開關狀態
do {
try realm.write {
alarmToUpdate.isEnabled = newStatus
}
} catch {
print("從主畫面更新鬧鐘開關狀態失敗: \(error)")
}
// 重新載入特定行以更新 UI(注意這裡是 section 1)
let indexPathToReload = IndexPath(row: rowIndex, section: 1)
tbvAlarm.reloadRows(at: [indexPathToReload], with: .none)
}
最後,我實作了 tableView(_:trailingSwipeActionsConfigurationForRowAt:)
來處理「左滑刪除」,並同時處理了編輯模式下的刪除。它們最終都會呼叫我寫好的 deleteAlarm(_:)
方法,在 Realm 中安全地移除該筆資料。
檔案:MainViewController.swift
// In extension MainViewController: UITableViewDataSource, UITableViewDelegate
// 設置右滑動作
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard indexPath.section == 1 else { return nil }
let deleteAction = UIContextualAction(style: .destructive, title: "刪除") { [weak self] (_, _, completionHandler) in
guard let self = self else { return }
let alarmToDelete = self.alarms[indexPath.row]
self.deleteAlarm(alarmToDelete)
completionHandler(true)
}
return UISwipeActionsConfiguration(actions: [deleteAction])
}
// 處理編輯模式下的刪除
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete && indexPath.section == 1 {
let alarmToDelete = alarms[indexPath.row]
deleteAlarm(alarmToDelete)
}
}
// 控制只有鬧鐘列表(section 1)可以被編輯
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return indexPath.section == 1
}
今天我們成功地將 MainViewController
與 Realm 資料庫連接,用真實的 alarms
數據取代了原本的假資料。同時,我們不僅可以透過鬧鐘開關即時更新資料庫狀態與 UI,更能透過左滑手勢和編輯模式來刪除不再需要的鬧鐘。
明天,我們將深入今天建立的 AddAlarmViewController
,打造一個功能完整的新增與編輯鬧鐘頁面!