iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
生成式 AI

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

Day 15 - 【鬧鐘實戰 VI】細節打磨:實現「重複」與「提示聲」設定

  • 分享至 

  • xImage
  •  

昨天,我們完成了新增與編輯鬧鐘的核心功能,成功打通了 App 的數據主動脈。現在,需要將右邊的 > 指示選項背後的子功能頁面實作出來。

  • 困惑點:多層頁面之間,資料要如何「傳遞」與「回傳」?

  • AI 協作

    • 我的 Prompt:我正在製作iOS鬧鐘的『新增鬧鐘』頁面,其中有『重複』和『提示聲』兩個選項,點擊後需要跳到新的頁面進行設定。請問,實現這種『推入式』的頁面跳轉,以及將子頁面的選擇結果回傳給父頁面,最佳的架構實踐是什麼?Delegate 模式在這裡依然適用嗎?請簡單說明並不用給程式碼

    AI 的回答:

    在iOS開發中,要實現這種「推入式」(push-style)的頁面跳轉,並將子頁面的選擇結果回傳給父頁面,最常使用且最佳的架構模式是採用 Navigation ControllerDelegate 模式

    頁面跳轉:Navigation Controller

    對於你所描述的『推入式』頁面跳轉,Navigation Controller是標準的解決方案。它會維護一個視圖控制器(view controller)的堆疊(stack),你可以透過以下方法操作:

    1. Push (推入):當使用者點擊『重複』或『提示聲』選項時,你可以將新的設定頁面(例如 RepeatSettingViewControllerSoundSettingViewController推入到 Navigation Controller 的堆疊頂部。
    2. Pop (彈出):當使用者在子頁面完成設定並點擊返回時,你可以將該子頁面從堆疊中彈出,回到前一個父頁面。

    Navigation Controller 不僅提供了這種流暢的視覺過渡效果,也自動處理了導覽列(navigation bar)和返回按鈕,大大簡化了開發工作。

    資料回傳:Delegate 模式


    是的,Delegate 模式在這裡依然是最佳且最推薦的實踐方式。

    當子頁面完成設定後,需要將選擇的結果(例如:設定的重複日期、選擇的提示聲名稱)回傳給父頁面。Delegate 模式完美地解決了這個問題,其運作方式簡單來說是:

    1. 定義委託(Delegate)協定(Protocol):在你的子頁面(例如 RepeatSettingViewController)中,定義一個協定,宣告一個或多個方法,用來傳遞資料。
    2. 設定委託:在父頁面(AddAlarmViewController)準備推入子頁面之前,將父頁面自身設定為子頁面的 委託(Delegate)
    3. 呼叫委託方法:當使用者在子頁面完成選擇時,子頁面會透過其 delegate 屬性,呼叫父頁面所遵循的協定方法,並將資料作為參數傳遞回去。
    4. 接收資料:父頁面(作為委託方)的委託方法會被觸發,並接收到子頁面傳來的資料,然後更新自己的 UI 或狀態。

    為何選擇 Delegate 模式?

    Delegate 模式相較於其他回傳資料的方法(如 Completion HandlerNotification),在這種情境下有幾個優勢:

    • 強關聯性與清晰的責任劃分:它清楚地定義了「誰」需要通知「誰」,以及「如何」通知。子頁面知道它需要通知一個特定的委託者,而父頁面也明確知道它需要實現哪些方法來接收資料。
    • 直接溝通:它允許子頁面與父頁面進行一對一的直接溝通,不需要透過全域或廣播機制,降低了耦合性。
    • 程式碼可讀性:遵循 protocol 的方式,使程式碼的意圖更為清晰。當你閱讀父頁面的程式碼時,可以很清楚地看到它遵循了某個子頁面的委託協定,這表示它有責任處理來自該子頁面的回傳資料。

    總結來說,使用 Navigation Controller 處理頁面堆疊管理與轉場,並搭配 Delegate 模式 進行資料回傳,是iOS開發中實現你所描述功能最標準、最專業且最為推薦的架構實踐。

    • 根據 AI 的建議,我把詳細的實作步驟整理如下:
      1. 實現「重複」設定頁面 (RepeatViewController):從零建立 UI 與 UITableView,並定義好代理協定與回傳邏輯。
      2. 整合「重複」頁面:回到 AddAlarmViewController,實現頁面 push 跳轉、遵守代理協定、接收回傳值,並更新重複狀態的顯示文字。
      3. 實現「提示聲」設定頁面 (SoundViewController):重複類似的流程,但額外加入 AVFoundation 來實現音效預覽播放。
      4. 整合「提示聲」頁面:再次回到 AddAlarmViewController,完成跳轉與代理的整合。

步驟一:實現「重複」設定頁面 (RepeatViewController)

我先在 Controller 資料夾中,建立了 RepeatViewController.swift.xib 檔案。

設計 UI:打開 RepeatViewController.xib,從物件庫 (+) 拖曳一個 UITableView 到畫布上,並設定其 Auto Layout 約束,使其上、下、左、右都與安全區域邊距為 0,填滿整個畫面,並根據以下圖片設定。
image image

接著,打開 RepeatViewController.swift,完成所有程式碼的撰寫。

檔案RepeatViewController.swift

import UIKit

// 1. 定義代理協定,用於將選擇結果回傳
protocol RepeatViewControllerDelegate: AnyObject {
    func didUpdateRepeatDays(_ days: [Bool])
}

class RepeatViewController: UIViewController {

    // MARK: - IBOutlet
    @IBOutlet weak var tbvRepeat: UITableView!

    // MARK: - Properties
    weak var delegate: RepeatViewControllerDelegate?
    let daysOfWeek = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]
    // 這個變數將由 AddAlarmViewController 傳入,代表當前的選擇狀態
    var selectedDays: [Bool] = Array(repeating: false, count: 7)

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    private func setupUI() {
        title = "重複"
        tbvRepeat.dataSource = self
        tbvRepeat.delegate = self
        tbvRepeat.register(UITableViewCell.self, forCellReuseIdentifier: "DayCell")
    }
}

// MARK: - UITableViewDataSource & UITableViewDelegate
extension RepeatViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return daysOfWeek.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "DayCell", for: indexPath)
        cell.textLabel?.text = daysOfWeek[indexPath.row]
        // 根據 selectedDays 陣列中的布林值,決定是否顯示打勾符號
        cell.accessoryType = selectedDays[indexPath.row] ? .checkmark : .none
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        // 2. 當使用者點擊某一行,反轉該行的選擇狀態
        selectedDays[indexPath.row].toggle()

        // 3. 即時透過代理,將更新後的整個 selectedDays 陣列回傳給上一頁
        delegate?.didUpdateRepeatDays(selectedDays)

        // 4. 只刷新被點擊的那一行,更新打勾符號,動畫效果更平滑
        tableView.reloadRows(at: [indexPath], with: .automatic)
    }
}

步驟二:整合「重複」頁面到 AddAlarmViewController

RepeatVewController 準備就緒後,我回到 AddAlarmViewController.swift 進行整合。

首先,新增tableView(_:didSelectRowAt:)case 0(點擊「重複」)中,加入了跳轉邏輯。

檔案:AddAlarmViewController.swift

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)

    if indexPath.section == 0 {
        switch indexPath.row {
        case 0: // 點擊了「重複」
            let repeatVC = RepeatViewController(nibName: "RepeatViewController", bundle: nil)
            repeatVC.selectedDays = self.repeatDays // 傳入當前的選擇狀態
            repeatVC.delegate = self // 設定代理人為自己
            // 使用 push 的方式,將新頁面推入到當前的導航堆疊中
            navigationController?.pushViewController(repeatVC, animated: true)

        //... case 2...
        default:
            break
        }
    } // ... else ...
}

接著,讓 AddAlarmViewController 遵守 RepeatViewControllerDelegate 協定,並實作 didUpdateRepeatDays 方法。

檔案:AddAlarmViewController.swift

// 在 AddAlarmViewController.swift 底部
// MARK: - 重複設定代理
extension AddAlarmViewController: RepeatViewControllerDelegate {
    func didUpdateRepeatDays(_ days: [Bool]) {
        // 當 RepeatViewController 回傳新的選擇狀態時,更新自身的屬性
        self.repeatDays = days
        // 刷新列表,讓 detailTextLabel 顯示最新的狀態
        self.tbvTime.reloadData()
    }
}

最後,我完成了 cellForRowAtcase 0 的顯示邏輯,讓 detailTextLabel 能根據 repeatDays 的內容,智慧地顯示「永不」、「每天」、「每個平日」等文字。

檔案:AddAlarmViewController.swift

// 在 cellForRowAt, case 0:

case 0: // 重複設定
    let cell = UITableViewCell(style: .value1, reuseIdentifier: "DefaultCell")
    cell.textLabel?.text = "重複"
    cell.accessoryType = .disclosureIndicator

    // 設定重複天數的顯示文字
    let daysOfWeek = ["週日", "週一", "週二", "週三", "週四", "週五", "週六"]
    let weekdaysTemplate = [false, true, true, true, true, true, false] // 平日模板
    let weekendTemplate = [true, false, false, false, false, false, true] // 週末模板

    let selectedDays = self.repeatDays.enumerated().filter { $0.1 }.map { daysOfWeek[$0.0] }

    var detailText = "永不"
    if self.repeatDays == weekdaysTemplate {
        detailText = "每個平日"
    } else if self.repeatDays == weekendTemplate {
        detailText = "每個週末"
    } else if selectedDays.count == 7 {
        detailText = "每天"
    } else if selectedDays.count > 0 {
        detailText = selectedDays.joined(separator: "、")
    }
    cell.detailTextLabel?.text = detailText

    return cell

步驟三:實現「提示聲」設定頁面 (SoundViewController)

「提示聲」頁面的製作流程與「重複」頁面非常相似。
我建立了 SoundViewController.swift 及其 .xib 檔案,在 .xib 中加入一個 UITableView,並根據以下圖片設定。同時,也將需要的 mp3 音檔拖曳到專案中。
image

image image

檔案:SoundViewController.swift

import UIKit
import AVFoundation // 引入音訊播放函式庫

protocol SoundViewControllerDelegate: AnyObject {
    func didSelectSound(_ sound: String)
}

class SoundViewController: UIViewController {

    @IBOutlet weak var tbvSound: UITableView!

    weak var delegate: SoundViewControllerDelegate?
    var audioPlayer: AVAudioPlayer?
    // 記得把下載下來的鈴聲放進專案
    let sounds = ["警報", "計時器", "lingling", "Oversimplified", "逼逼逼"]
    var currentSelectedSound: String = "警報"

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    private func setupUI() {
        title = "提示聲"
        tbvSound.dataSource = self
        tbvSound.delegate = self
        tbvSound.register(UITableViewCell.self, forCellReuseIdentifier: "SoundCell")
    }

    // 播放聲音的輔助方法
    private func playSound(named soundName: String) {
        guard let url = Bundle.main.url(forResource: soundName, withExtension: "mp3") else {
            print("錯誤:找不到名為 \\(soundName).mp3 的音檔。")
            return
        }
        do {
            audioPlayer = try AVAudioPlayer(contentsOf: url)
            audioPlayer?.play()
        } catch {
            print("無法播放音效: \\(error.localizedDescription)")
        }
    }
}

extension SoundViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return sounds.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "SoundCell", for: indexPath)
        let soundName = sounds[indexPath.row]
        cell.textLabel?.text = soundName
        cell.accessoryType = (soundName == currentSelectedSound) ? .checkmark : .none
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        let selectedSound = sounds[indexPath.row]
        currentSelectedSound = selectedSound

        playSound(named: selectedSound) // 播放預覽
        delegate?.didSelectSound(selectedSound) // 透過代理回傳
        tableView.reloadData() // 刷新列表以移動打勾符號
    }
}

步驟四:整合「提示聲」頁面到 AddAlarmViewController

最後,我回到 AddAlarmViewController,將「提示聲」頁面整合進來。

首先,在 didSelectRowAt 中為 case 2 加入跳轉邏輯。

檔案:AddAlarmViewController.swift

// 在 AddAlarmViewController's didSelectRowAt, switch case

case 2: // 點擊了「提示聲」
    let soundVC = SoundViewController(nibName: "SoundViewController", bundle: nil)
    soundVC.currentSelectedSound = self.selectedSound // 傳入當前選擇的聲音
    soundVC.delegate = self // 設定代理人
    navigationController?.pushViewController(soundVC, animated: true)

接著,讓 AddAlarmViewControllerextension 同時遵守 SoundViewControllerDelegate,並實作其方法。(修改extension AddAlarmViewController: RepeatViewControllerDelegate {

檔案:AddAlarmViewController.swift

// MARK: - 子功能頁面代理
extension AddAlarmViewController: RepeatViewControllerDelegate, SoundViewControllerDelegate {
    // RepeatViewController 的方法 (已存在)
    func didUpdateRepeatDays(_ days: [Bool]) {
        self.repeatDays = days
        self.tbvTime.reloadData()
    }

    // SoundViewController 的方法 (新增)
    func didSelectSound(_ soundName: String) {
        self.selectedSound = soundName
        self.tbvTime.reloadData()
    }
}

驗證點

現在,運行您的 App:

  • 進入「加入/編輯鬧鐘」頁面。
  • 點擊「重複」行,應該能跳轉到星期選擇頁面,勾選後主選項頁的文字會即時更新。
  • 點擊「提示聲」行,應該能跳轉到聲音列表,點擊不同的聲音會聽見預覽,同時主選項頁的文字也會即時更新。
  • 所有設定(包括重複和提示聲)在點擊「儲存」後,都能正確地寫入資料庫。
    imageimageimage

鬧鐘的所有細節設定功能都已完成。下一章,我們將為它裝上最後的通知系統,讓鬧鐘能在指定的時間真正地響起來!


上一篇
Day 14 - 【鬧鐘實戰 V】鬧鐘的誕生:打造新增與編輯畫面
系列文
iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言