iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
生成式 AI

iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線系列 第 14

Day 14 - 【鬧鐘實戰 V】鬧鐘的誕生:打造新增與編輯畫面

  • 分享至 

  • xImage
  •  

昨天,我們的 App 終於活了起來!可以從資料庫讀取、刪除鬧鐘,列表也能響應我們的操作。但它還缺少一個最關鍵的功能:新增鬧鐘。我們的「+」按鈕只會打開一個空白的頁面,今天,我們就要把 AddAlarmViewController 打造出來。

  • 困惑點:如何地打造一個能同時處理「新增鬧鐘」和「編輯鬧鐘」兩種的頁面。這兩種模式 UI 大同小異,但邏輯不同,該如何用同一份程式碼來處理?

  • AI 協作

    • 我的 Prompt:我需要建立一個新增/編輯鬧鐘的頁面。它包含一個時間選擇器和一個設定列表(重複、標籤等)。請問,用 UITableView 來做這個設定列表是個好主意嗎?另外,對於在同一個 ViewController 中處理『新增』和『編輯』兩種模式,有沒有什麼推薦的設計模式?請簡單說明且不需要給程式碼

    AI 的回答:

    您好,這是一個很常見的 iOS 應用程式設計問題。針對您的需求,以下是我的專業建議:

    使用 UITableView 來製作設定列表

    使用 UITableView 是個非常好的主意。 這不僅是 iOS 開發中的標準做法,也提供了許多優勢:

    1. 使用者介面一致性 (UI Consistency): 幾乎所有 Apple 內建的應用程式,如「時鐘」、「聯絡資訊」、「設定」等,都使用 UITableView 來顯示這類階層式或靜態的設定列表。這讓使用者感到熟悉,降低了學習成本。
    2. 彈性與可擴充性 (Flexibility & Scalability): 您可以輕鬆地增加、刪除或重新排序列表項目(如:新增「鈴聲」選項)。每個 cell 都可以包含不同的 UI 元件(如:UILabelUISwitchUIButton),滿足各種設定需求。
    3. 效能優化 (Performance Optimization): UITableView 透過 cell 重用機制 (Cell Reuse Mechanism),只渲染目前可見的 cell。這對於包含大量資料的列表非常有效,儘管在您的「鬧鐘設定」頁面中資料量不大,但這種高效的架構設計仍然是最佳實踐。
    4. 靜態與動態內容的完美結合: 像「鬧鐘」這種固定數量的設定項目,非常適合使用 Static Table View。您不需要寫任何 delegatedata source 的程式碼,只需在 Storyboard 或 Interface Builder 中直接拖曳和配置 cell,大大簡化了開發流程。

    處理「新增」和「編輯」模式的設計模式

    在同一個 ViewController 中處理兩種模式(新增與編輯)是一種高效且推薦的設計。您可以使用以下兩種常見的設計模式:

    1. 單一模型 (Single Model)

    這是最簡單、最直接的方法。當您從上一個頁面導航到這個「鬧鐘設定」頁面時,可以傳遞一個參數來區分當前模式。

    • 新增模式: 不傳入任何鬧鐘資料,ViewController 內部會建立一個新的、空的鬧鐘物件。
    • 編輯模式: 傳入一個現有的鬧鐘物件。ViewController 使用這個物件的資料來填充所有 UI 元件(如:時間選擇器、重複選項等)。

    簡單來說,您只需要在 viewDidLoadviewWillAppear 方法中,根據傳入的鬧鐘物件是否為空,來決定是初始化一個新鬧鐘還是載入現有資料。

    2. 狀態模式 (State Pattern)

    雖然對於這種簡單的場景可能有點過度設計,但它是一種更強大、更具可擴展性的設計模式。您可以定義一個 Mode 列舉 (Enum),例如 enum Mode { case add, edit(Alarm) }

    • 新增模式: 設定 ViewControllermode 屬性為 .add
    • 編輯模式: 設定 mode 屬性為 .edit(existingAlarm)

    這種方法的優點在於,所有與模式相關的邏輯都集中在一個地方,使得程式碼更清晰。例如,您可以根據 mode 的值來決定導航列的標題("新增鬧鐘" 或 "編輯鬧鐘")。

    • 根據 AI 的建議,我把詳細的實作步驟整理如下:
      1. 準備零件:先建立一個等等會用到的自訂 Cell:LabelTextFieldCell
      2. 佈局主體 UI:在 AddAlarmViewController.xib 中,放置好 UIDatePickerUITableView
      3. 注入靈魂:在 AddAlarmViewController.swift 中,加入所有必要的屬性(包含關鍵的 alarmToEdit),並完成 UI 的基礎設定。
      4. 實現 UITableViewDataSource:讓設定列表能根據不同模式(新增/編輯),顯示出對應的選項。
      5. 實現資料填充與跳轉:打通從主畫面點擊 Cell,將 AlarmData 物件傳入編輯頁的數據流。
      6. 實現文字輸入:透過 UITextFieldDelegate 讓標籤輸入框能正常工作。
      7. (最後一步) 實現儲存邏輯:完成 saveTapped 方法,根據是新增還是編輯模式,將資料正確地寫入 Realm 資料庫。

步驟一:準備零件:建立 LabelTextFieldCell

在設定選項中,我們需要一個左邊是標題、右邊是輸入框的 Cell 來讓使用者輸入鬧鐘標籤。

  1. View/Cells 資料夾上右鍵,建立一個新的 Cocoa Touch Class

    • Class: LabelTextFieldCell
    • Subclass of: UITableViewCell
    • 勾選 "Also create XIB file"
  2. 設計 UI:打開 LabelTextFieldCell.xib

    • 從物件庫 (+) 拖曳一個 UILabel 到畫布左側,再拖曳一個 UITextField 到右側。

    • 設定 UILabel 的約束:左邊距 (Leading) 設為 20,並設定垂直置中 (Vertically Center in Container)。

    • 設定 UITextField 的約束

      • 與左側 UILabel 的水平間距 (Horizontal Spacing) 設為 8
      • 右邊距 (Trailing) 設為 16
      • 設定垂直置中 (Vertically Center in Container)。
    • 在屬性檢查器中,將 UITextFieldPlaceholder 設為「鬧鐘」,Border Style 設為無。

      image

    image

  3. 連接 IBOutlet:使用助理編輯器,將 UILabel 連接為 lbTitleUITextField 連接為 txfTitle

  4. 加入 Identifier: 在 LabelTextFieldCell.swift 中加入靜態 identifier

檔案:LabelTextFieldCell.swift

class LabelTextFieldCell: UITableViewCell {
    static let identifier = "LabelTextFieldCell"
    @IBOutlet weak var lbTitle: UILabel!
    @IBOutlet weak var txfTitle: UITextField!
    //...
}

步驟二:佈局 AddAlarmViewController 的主體 UI

這個頁面的結構很簡單:上方是一個時間選擇器,下方是一個設定選項的列表。

  1. 打開 Controller/AddAlarmViewController.xib
  2. 從物件庫 (+) 拖曳一個 Date Picker 到畫布頂部。
    • 設定約束:將其上 (Top)、左 (Leading)、右 (Trailing) 都與父視圖邊距設為 0
  3. 從物件庫 (+) 拖曳一個 Table View 到 Date Picker 的下方。
    • 設定約束:將其左 (Leading)、右 (Trailing)、下 (Bottom) 與父視圖邊距設為 0,並設定其頂部 (Top) 與 Date Picker 的底部 (Bottom) 間距為 0
  4. 連接 IBOutlet: 使用助理編輯器,將 Date Picker 連接為 dpkTime,Table View 連接為 tbvTime

image

步驟三:在 AddAlarmViewController.swift 中加入屬性與 UI 設定

現在我們來為這個頁面注入靈魂。我打開 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
    }
}

步驟四:實現 UITableViewDataSource,顯示所有選項

我透過 extensionAddAlarmViewController 遵守 UITableViewDataSourceUITableViewDelegate,並在其中實作了顯示所有設定選項的邏輯。特別的是,我利用 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,現在鬧鐘最核心的數據流已經完全打通了!

  • 可以點擊「+」進入新增頁面,設定完時間和標籤後點擊「儲存」,新的鬧鐘就會出現在主畫面上。
  • 也可以點擊主畫面上已有的鬧鐘,進入編輯頁面,所有的資料都被正確地載入了。
  • 修改時間或標籤後再次儲存,主畫面上的鬧鐘資訊也會同步更新。
    imageimageimage

明天,我們將把今天暫時留白的「重複」和「提示聲」這兩個子功能的頁面完成,讓我們的鬧鐘 App 更加完整。


上一篇
Day 13 - 【鬧鐘實戰 IV】注入靈魂:連接 Realm 數據與左滑刪除
下一篇
Day 15 - 【鬧鐘實戰 VI】細節打磨:實現「重複」與「提示聲」設定
系列文
iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言