昨天,我們完成了新增與編輯鬧鐘的核心功能,成功打通了 App 的數據主動脈。現在,需要將右邊的 >
指示選項背後的子功能頁面實作出來。
困惑點:多層頁面之間,資料要如何「傳遞」與「回傳」?
AI 協作
我正在製作iOS鬧鐘的『新增鬧鐘』頁面,其中有『重複』和『提示聲』兩個選項,點擊後需要跳到新的頁面進行設定。請問,實現這種『推入式』的頁面跳轉,以及將子頁面的選擇結果回傳給父頁面,最佳的架構實踐是什麼?Delegate 模式在這裡依然適用嗎?請簡單說明並不用給程式碼
AI 的回答:
在iOS開發中,要實現這種「推入式」(push-style)的頁面跳轉,並將子頁面的選擇結果回傳給父頁面,最常使用且最佳的架構模式是採用 Navigation Controller 和 Delegate 模式。
頁面跳轉:Navigation Controller
對於你所描述的『推入式』頁面跳轉,Navigation Controller是標準的解決方案。它會維護一個視圖控制器(view controller)的堆疊(stack),你可以透過以下方法操作:
- Push (推入):當使用者點擊『重複』或『提示聲』選項時,你可以將新的設定頁面(例如
RepeatSettingViewController
或SoundSettingViewController
)推入到 Navigation Controller 的堆疊頂部。- Pop (彈出):當使用者在子頁面完成設定並點擊返回時,你可以將該子頁面從堆疊中彈出,回到前一個父頁面。
Navigation Controller 不僅提供了這種流暢的視覺過渡效果,也自動處理了導覽列(navigation bar)和返回按鈕,大大簡化了開發工作。
資料回傳:Delegate 模式
是的,Delegate 模式在這裡依然是最佳且最推薦的實踐方式。
當子頁面完成設定後,需要將選擇的結果(例如:設定的重複日期、選擇的提示聲名稱)回傳給父頁面。Delegate 模式完美地解決了這個問題,其運作方式簡單來說是:
- 定義委託(Delegate)協定(Protocol):在你的子頁面(例如
RepeatSettingViewController
)中,定義一個協定,宣告一個或多個方法,用來傳遞資料。- 設定委託:在父頁面(
AddAlarmViewController
)準備推入子頁面之前,將父頁面自身設定為子頁面的 委託(Delegate)。- 呼叫委託方法:當使用者在子頁面完成選擇時,子頁面會透過其
delegate
屬性,呼叫父頁面所遵循的協定方法,並將資料作為參數傳遞回去。- 接收資料:父頁面(作為委託方)的委託方法會被觸發,並接收到子頁面傳來的資料,然後更新自己的 UI 或狀態。
為何選擇 Delegate 模式?
Delegate 模式相較於其他回傳資料的方法(如
Completion Handler
或Notification
),在這種情境下有幾個優勢:
- 強關聯性與清晰的責任劃分:它清楚地定義了「誰」需要通知「誰」,以及「如何」通知。子頁面知道它需要通知一個特定的委託者,而父頁面也明確知道它需要實現哪些方法來接收資料。
- 直接溝通:它允許子頁面與父頁面進行一對一的直接溝通,不需要透過全域或廣播機制,降低了耦合性。
- 程式碼可讀性:遵循
protocol
的方式,使程式碼的意圖更為清晰。當你閱讀父頁面的程式碼時,可以很清楚地看到它遵循了某個子頁面的委託協定,這表示它有責任處理來自該子頁面的回傳資料。總結來說,使用 Navigation Controller 處理頁面堆疊管理與轉場,並搭配 Delegate 模式 進行資料回傳,是iOS開發中實現你所描述功能最標準、最專業且最為推薦的架構實踐。
RepeatViewController
):從零建立 UI 與 UITableView
,並定義好代理協定與回傳邏輯。AddAlarmViewController
,實現頁面 push
跳轉、遵守代理協定、接收回傳值,並更新重複狀態的顯示文字。SoundViewController
):重複類似的流程,但額外加入 AVFoundation
來實現音效預覽播放。AddAlarmViewController
,完成跳轉與代理的整合。我先在 Controller
資料夾中,建立了 RepeatViewController
的 .swift
和 .xib
檔案。
設計 UI:打開 RepeatViewController.xib,從物件庫 (+) 拖曳一個 UITableView 到畫布上,並設定其 Auto Layout 約束,使其上、下、左、右都與安全區域邊距為 0,填滿整個畫面,並根據以下圖片設定。
接著,打開 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)
}
}
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()
}
}
最後,我完成了 cellForRowAt
中 case 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.swift 及其 .xib 檔案,在 .xib 中加入一個 UITableView,並根據以下圖片設定。同時,也將需要的 mp3 音檔拖曳到專案中。
檔案: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
,將「提示聲」頁面整合進來。
首先,在 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)
接著,讓 AddAlarmViewController
的 extension
同時遵守 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:
鬧鐘的所有細節設定功能都已完成。下一章,我們將為它裝上最後的通知系統,讓鬧鐘能在指定的時間真正地響起來!