歡迎回到 macOS 開發(fā)教程初學(xué)者系列 3 部分中的第 3 部分,也是最后一個(gè)部分!
在第 1 部分中,學(xué)習(xí)了如何安裝 Xcode 以及創(chuàng)建簡(jiǎn)單的 app。在 第 2 部分 中,為更復(fù)雜的 app 創(chuàng)建了用戶界面,但還不能正常工作,因?yàn)闆]有寫任何代碼。在這部分,會(huì)添加 Swift 代碼,以使 app 正常工作!
開始
如果尚未完成第 2 部分或希望用干凈的模板開始,可 下載項(xiàng)目文件,帶有布局好的 UI,就和第 2 部分結(jié)尾的時(shí)候一樣。打開此項(xiàng)目或你自己在第 2 部分里的項(xiàng)目,運(yùn)行一下確定 UI 已全部就位。同樣也把 Preferences 打開檢查一下。
沙盒
在你深入代碼之前,花一點(diǎn)時(shí)間學(xué)習(xí)沙盒(sandboxing)。如果你是一個(gè) iOS 程序員,你已經(jīng)熟悉這個(gè)概念——否則就請(qǐng)繼續(xù)閱讀。
沙盒 app 有自己的空間,可以使用單獨(dú)的文件存儲(chǔ)區(qū)域,無法訪問其他 app 創(chuàng)建的文件,具有有限的訪問權(quán)限。對(duì)于 iOS app,這是唯一的選擇。對(duì)于 macOS app,這是可選的;但是,如果要通過 Mac App Store 分發(fā) app,則必須將其沙盒化。一般情況下,都應(yīng)將 app 沙盒化,因?yàn)檫@使 app 減少潛在問題。
要為 Egg Timer app 啟用沙盒,請(qǐng)?jiān)?Project Navigator 中選擇項(xiàng)目——頂部帶有藍(lán)色圖標(biāo)的那個(gè)。在 Targets 中選擇 EggTimer(只列出了一個(gè) target),然后單擊頂部選項(xiàng)卡中的 Capabilities。單擊開關(guān)以啟用 App Sandbox。屏幕會(huì)展開,以顯示現(xiàn)在 app 可以請(qǐng)求的各種權(quán)限。這個(gè) app 什么都不需要,所以不要勾選它們。
組織文件
看看 Project Navigator。列出了所有文件,但毫無紀(jì)律。這個(gè) app 不會(huì)有很多文件,但把類似的文件分組在一起是好的做法,可以更有效的導(dǎo)航,特別是對(duì)于較大的項(xiàng)目來說。
選擇兩個(gè)視圖控制器文件,方法是單擊一個(gè),然后按住 Shift 鍵單擊下一個(gè)。右鍵單擊并從彈出菜單中選擇 New Group from Selection。將新組命名為 View Controllers。
該項(xiàng)目馬上會(huì)有一些模型文件,因此選擇頂部 EggTimer 組,右鍵單擊并選擇 New Group。取名為 Model。
最后,選擇 Info.plist 和 EggTimer.entitlements,并將它們放入名為 Supporting Files 的組。
拖動(dòng)組和文件,直到 Project Navigator 看起來像這樣:
MVC
這個(gè) app 使用 MVC 模式:Model View Controller。
app 的主要模型將是一個(gè)名為 EggTimer 的類。這個(gè)類將具有定時(shí)器的開始時(shí)間、所請(qǐng)求的持續(xù)時(shí)間和已經(jīng)過去的時(shí)間等屬性。它還會(huì)有一個(gè) Timer 對(duì)象,每秒觸發(fā)、自我更新。EggTimer 對(duì)象還會(huì)有 start,stop,resume 和 reset 方法。
EggTimer 模型類保存數(shù)據(jù)并執(zhí)行操作,但不了解如何顯示它們。 Controller(在這種情況下是 ViewController)了解 EggTimer 類(Model),并且有一個(gè) View 可以用來顯示數(shù)據(jù)。
為了與 ViewController 通信,EggTimer 使用委托協(xié)議。當(dāng)某事發(fā)生變化時(shí),EggTimer 向其 delegate 發(fā)送一條消息。ViewController 將自身分配為 EggTimer 的 delegate,所以由它來接收消息,然后它可以在自己的 View 中顯示新的數(shù)據(jù)。
編寫 EggTimer
在 Project Navigator 里選擇 Model 組,然后選擇 File/New/File…,選擇 macOS/Swift File 然后點(diǎn)擊 Next。將文件命名為 EggTimer.swift,然后點(diǎn)擊 Create 以保存它。
添加如下代碼:
class EggTimer {
var timer: Timer? = nil
var startTime: Date?
var duration: TimeInterval = 360 // default = 6 minutes
var elapsedTime: TimeInterval = 0
}
這樣就設(shè)置了 EggTimer 類及其屬性。 TimeInterval 實(shí)際上是 Double,意思為秒數(shù)。
接下來要在類中添加兩個(gè)計(jì)算屬性,就在前面那些屬性之后:
var isStopped: Bool {
return timer == nil && elapsedTime == 0
}
var isPaused: Bool {
return timer == nil && elapsedTime > 0
}
這是用于快速確定 EggTimer 狀態(tài)的方式。
將 delegate 協(xié)議的定義插入 EggTimer.swift 文件,但在 EggTimer 類的外面——我喜歡將協(xié)議定義放在文件的頂部,import 的后面。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
協(xié)議規(guī)定了一個(gè)契約,任何符合 EggTimerProtocol 的對(duì)象必須提供這兩個(gè)函數(shù)。
現(xiàn)在你已經(jīng)定義了一個(gè)協(xié)議,EggTimer 需要一個(gè)可選的 delegate 屬性,該屬性設(shè)置為符合此協(xié)議的任何對(duì)象。EggTimer 不知道或不關(guān)心 delegate 是什么類型的對(duì)象,因?yàn)樗灰_定 delegate 有這兩個(gè)函數(shù)就行了。
將此行添加到 EggTimer 類中的現(xiàn)有屬性中:
var delegate: EggTimerProtocol?
啟動(dòng) EggTimer 的 timer 對(duì)象將每秒觸發(fā)一次函數(shù)調(diào)用。插入此代碼,定義了將由定時(shí)器調(diào)用的函數(shù)。必須要有關(guān)鍵字 dynamic,以便 Timer 能夠找到它。
dynamic func timerAction() {
// 1
guard let startTime = startTime else {
return
}
// 2
elapsedTime = -startTime.timeIntervalSinceNow
// 3
let secondsRemaining = (duration - elapsedTime).rounded()
// 4
if secondsRemaining <= 0 {
resetTimer()
delegate?.timerHasFinished(self)
} else {
delegate?.timeRemainingOnTimer(self, timeRemaining: secondsRemaining)
}
}
會(huì)發(fā)生什么?
-
startTime是一個(gè)Optional Date——如果是nil,timer 就無法運(yùn)行,所以什么都不會(huì)發(fā)生。 - 重新計(jì)算
elapsedTime屬性。startTime早于當(dāng)前,因此timeIntervalSinceNow會(huì)生成負(fù)數(shù)。用減號(hào)使得 elapsedTime 是正數(shù)。 - 計(jì)算 timer 的剩余秒數(shù),四舍五入以給出整數(shù)秒。
- 如果 timer 已經(jīng)完成,重置它并告訴 delegate 它已經(jīng)完成。否則,告訴 delegate 剩余的秒數(shù)。由于
delegate是可選屬性,? 號(hào)用于執(zhí)行可選鏈。如果 delegate 沒有設(shè)置,這些方法將不會(huì)被調(diào)用,也就不會(huì)出現(xiàn)意外情況了。
添加 EggTimer 類所需的最后一點(diǎn)代碼的時(shí)候,你會(huì)看到一個(gè)錯(cuò)誤:timer 的 starting, stopping, resuming 和 resetting 方法。
// 1
func startTimer() {
startTime = Date()
elapsedTime = 0
timer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(timerAction),
userInfo: nil,
repeats: true)
timerAction()
}
// 2
func resumeTimer() {
startTime = Date(timeIntervalSinceNow: -elapsedTime)
timer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(timerAction),
userInfo: nil,
repeats: true)
timerAction()
}
// 3
func stopTimer() {
// really just pauses the timer
timer?.invalidate()
timer = nil
timerAction()
}
// 4
func resetTimer() {
// stop the timer & reset back to start
timer?.invalidate()
timer = nil
startTime = nil
duration = 360
elapsedTime = 0
timerAction()
}
這些函數(shù)做了什么?
-
startTimer使用Date()將啟動(dòng)時(shí)間設(shè)置為現(xiàn)在、設(shè)置了重復(fù)的Timer。 -
resumeTimer是 timer 已暫停并正在重新啟動(dòng)時(shí)調(diào)用的內(nèi)容。基于已過去的時(shí)間重新計(jì)算開始時(shí)間。 -
stopTimer停止了重復(fù)的 timer。 -
resetTimer停止了重復(fù)的 timer 并將屬性恢復(fù)為默認(rèn)值。
這些函數(shù)還全部調(diào)用了 timerAction,以便屏幕可以立即刷新。
ViewController
現(xiàn)在 EggTimer 對(duì)象已經(jīng)正常工作了,現(xiàn)在回到 ViewController.swift 讓屏幕改變以反映這一點(diǎn)。
ViewController 已經(jīng)有 @IBOutlet 屬性了,現(xiàn)在給它一個(gè) EggTimer 屬性:
var eggTimer = EggTimer()
將下面這行駕到 viewDidLoad 中,替換掉注視行:
eggTimer.delegate = self
這將導(dǎo)致一個(gè)錯(cuò)誤,因?yàn)?ViewController 不符合 EggTimerProtocol。當(dāng)符合協(xié)議時(shí),為協(xié)議創(chuàng)建單獨(dú)的擴(kuò)展,會(huì)使代碼更干凈。在 ViewController 類定義下面添加這段代碼:
extension ViewController: EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
updateDisplay(for: timeRemaining)
}
func timerHasFinished(_ timer: EggTimer) {
updateDisplay(for: 0)
}
}
錯(cuò)誤消失了,因?yàn)?ViewController 現(xiàn)在有 EggTimerProtocol 所需的兩個(gè)函數(shù)。但是這兩個(gè)函數(shù)都調(diào)用了還不存在的 updateDisplay。
這是 ViewController 的另一個(gè)擴(kuò)展,包含了用于顯示的函數(shù):
extension ViewController {
// MARK: - Display
func updateDisplay(for timeRemaining: TimeInterval) {
timeLeftField.stringValue = textToDisplay(for: timeRemaining)
eggImageView.image = imageToDisplay(for: timeRemaining)
}
private func textToDisplay(for timeRemaining: TimeInterval) -> String {
if timeRemaining == 0 {
return "Done!"
}
let minutesRemaining = floor(timeRemaining / 60)
let secondsRemaining = timeRemaining - (minutesRemaining * 60)
let secondsDisplay = String(format: "%02d", Int(secondsRemaining))
let timeRemainingDisplay = "\(Int(minutesRemaining)):\(secondsDisplay)"
return timeRemainingDisplay
}
private func imageToDisplay(for timeRemaining: TimeInterval) -> NSImage? {
let percentageComplete = 100 - (timeRemaining / 360 * 100)
if eggTimer.isStopped {
let stoppedImageName = (timeRemaining == 0) ? "100" : "stopped"
return NSImage(named: stoppedImageName)
}
let imageName: String
switch percentageComplete {
case 0 ..< 25:
imageName = "0"
case 25 ..< 50:
imageName = "25"
case 50 ..< 75:
imageName = "50"
case 75 ..< 100:
imageName = "75"
default:
imageName = "100"
}
return NSImage(named: imageName)
}
}
updateDisplay 使用私有函數(shù)獲取剩余時(shí)間的文本和圖像,并在 text field 和 image view 中顯示它們。
textToDisplay 將剩余秒數(shù)轉(zhuǎn)換為 M:SS 格式。 imageToDisplay 計(jì)算煮蛋程度的百分比,并選擇匹配的圖像。
所以 ViewController 有了一個(gè) EggTimer 對(duì)象,它也有從 EggTimer 接收數(shù)據(jù)并顯示結(jié)果的函數(shù),但按鈕還沒有編碼。在第 2 部分中,已經(jīng)為按鈕設(shè)置了 @IBActions。
這里是這些 action 函數(shù)的代碼,把它們替換掉:
@IBAction func startButtonClicked(_ sender: Any) {
if eggTimer.isPaused {
eggTimer.resumeTimer()
} else {
eggTimer.duration = 360
eggTimer.startTimer()
}
}
@IBAction func stopButtonClicked(_ sender: Any) {
eggTimer.stopTimer()
}
@IBAction func resetButtonClicked(_ sender: Any) {
eggTimer.resetTimer()
updateDisplay(for: 360)
}
這3個(gè) action 調(diào)用之前添加的 EggTimer 方法。
現(xiàn)在構(gòu)建并運(yùn)行 app,然后單擊.Start 按鈕。
還少幾個(gè)功能:Stop 和 Reset 按鈕總是在禁用狀態(tài),以及只能煮一個(gè) 6 分鐘的蛋??梢允褂?Timer 菜單來控制 app; 嘗試使用菜單和鍵盤快捷鍵來停止,啟動(dòng)和重置。
如果足夠有耐心,你會(huì)看到煮的時(shí)候雞蛋變了顏色,最后在煮好時(shí)顯示了 “DONE!”。
根據(jù) timer 狀態(tài),按鈕應(yīng)該啟用或禁用,并且 Timer 菜單項(xiàng)應(yīng)該與之匹配。
將這個(gè)函數(shù)添加到 ViewController,放在用與顯示的 extension 里面:
func configureButtonsAndMenus() {
let enableStart: Bool
let enableStop: Bool
let enableReset: Bool
if eggTimer.isStopped {
enableStart = true
enableStop = false
enableReset = false
} else if eggTimer.isPaused {
enableStart = true
enableStop = false
enableReset = true
} else {
enableStart = false
enableStop = true
enableReset = false
}
startButton.isEnabled = enableStart
stopButton.isEnabled = enableStop
resetButton.isEnabled = enableReset
if let appDel = NSApplication.shared().delegate as? AppDelegate {
appDel.enableMenus(start: enableStart, stop: enableStop, reset: enableReset)
}
}
此函數(shù)使用 EggTimer 狀態(tài)(還記得添加到 EggTimer 的那幾個(gè)計(jì)算變量嗎)來確定應(yīng)啟用哪些按鈕。
在第 2 部分中,你把 Timer 菜單項(xiàng)設(shè)置為 AppDelegate 的屬性,因此AppDelegate 是配置它們的地方。
切換到 AppDelegate.swift 添加如下函數(shù):
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
為了在首次啟動(dòng) app 時(shí)正確配置菜單,請(qǐng)將此行添加到 applicationDidFinishLaunching 方法中:
enableMenus(start: true, stop: false, reset: false)
每當(dāng)按鈕或菜單項(xiàng)動(dòng)作改變 EggTimer 的狀態(tài)時(shí),就需要改變按鈕和菜單。切換回 ViewController.swift 并將此行添加到 3 個(gè)按鈕 action 函數(shù)中每一個(gè)的末尾:
configureButtonsAndMenus()
再次構(gòu)建并運(yùn)行 app,可以看到按鈕按預(yù)期啟用和禁用。檢查一下菜單項(xiàng);它們應(yīng)該會(huì)反映按鈕的狀態(tài)。
偏好設(shè)置
這個(gè) app 還有一個(gè)大問題——如果你不想把雞蛋煮 6 分鐘怎么辦?
在第 2 部分中,我們?cè)O(shè)計(jì)了 Preferences 窗口以允許選擇不同的時(shí)間。此窗口由 PrefsViewController 控制,但它需要一個(gè)模型對(duì)象來處理數(shù)據(jù)存儲(chǔ)以及檢索。
將使用 UserDefaults 存儲(chǔ) Preferences,UserDefaults 是在 app 容器中用鍵值對(duì)存儲(chǔ)小數(shù)據(jù)到 Preferences 文件夾中的方式。
右擊 Project Navigator 中的 Model 組,然后選擇 New File… 選擇 macOS/Swift File ,然后單擊 Next。將文件命名為 Preferences.swift ,然后單擊 Create。將此代碼添加到 Preferences.swift 文件:
struct Preferences {
// 1
var selectedTime: TimeInterval {
get {
// 2
let savedTime = UserDefaults.standard.double(forKey: "selectedTime")
if savedTime > 0 {
return savedTime
}
// 3
return 360
}
set {
// 4
UserDefaults.standard.set(newValue, forKey: "selectedTime")
}
}
}
這段代碼可以做什么?
- 叫做
selectedTime的計(jì)算變量定義為TimeInterval。 - 請(qǐng)求變量的值時(shí),
UserDefaults單例取出分配給鍵 “selectedTime” 的Double值。如果值未定義,UserDefaults將返回零,但如果值大于 0,則將其作為selectedTime的值返回。 - 如果
selectedTime沒有被定義,使用默認(rèn)值 360(6 分鐘)。 -
selectedTime被改變的時(shí)候,將新值寫入UserDefaults的鍵 “selectedTime”。
因此,通過使用帶有 getter 和 setter 的計(jì)算變量,UserDefaults 的數(shù)據(jù)存儲(chǔ)將被自動(dòng)處理。
現(xiàn)在切換到 PrefsViewController.swift,第一件事是更新顯示以反映現(xiàn)有偏好設(shè)置或默認(rèn)值。
首先,在 outlets 下面添加此屬性:
var prefs = Preferences()
在這里,你創(chuàng)建了一個(gè) Preferences 實(shí)例,以便訪問 selectedTime 計(jì)算變量。
然后,添加這些方法:
func showExistingPrefs() {
// 1
let selectedTimeInMinutes = Int(prefs.selectedTime) / 60
// 2
presetsPopup.selectItem(withTitle: "Custom")
customSlider.isEnabled = true
// 3
for item in presetsPopup.itemArray {
if item.tag == selectedTimeInMinutes {
presetsPopup.select(item)
customSlider.isEnabled = false
break
}
}
// 4
customSlider.integerValue = selectedTimeInMinutes
showSliderValueAsText()
}
// 5
func showSliderValueAsText() {
let newTimerDuration = customSlider.integerValue
let minutesDescription = (newTimerDuration == 1) ? "minute" : "minutes"
customTextField.stringValue = "\(newTimerDuration) \(minutesDescription)"
}
看上去有很多代碼,一步步看一遍:
- 請(qǐng)求 prefs 對(duì)象的
selectedTime,并將其從秒數(shù)轉(zhuǎn)換為整數(shù)分鐘。 - 如果找不到匹配的預(yù)設(shè)值,請(qǐng)將默認(rèn)值設(shè)置為 “Custom”。
- 遍歷循環(huán)
presetsPopup中的菜單項(xiàng)檢查他們的 tag。還記得在第 2 部分中如何將 tag 設(shè)置為每個(gè)選項(xiàng)的分鐘數(shù)嗎?如果找到匹配,啟用該項(xiàng)目并退出循環(huán)。 - 設(shè)置滑塊的值并調(diào)用
showSliderValueAsText。 - showSliderValueAsText 為數(shù)字添加 “minute” 或 “minutes”,并在 text field中顯示。
現(xiàn)在,把這個(gè)添加到 viewDidLoad 中:
showExistingPrefs()
當(dāng)視圖加載后,調(diào)用顯示偏好設(shè)置的方法。記住,使用 MVC 模式,Preferences 模型對(duì)象不知道如何或何時(shí)被顯示——這由 PrefsViewController 管理。
所以現(xiàn)在有顯示設(shè)置的時(shí)間的能力了,但改變彈出窗口中的時(shí)間并不做任何事情。我們需要一個(gè)保存新數(shù)據(jù)的方法,并告知有興趣的對(duì)象數(shù)據(jù)已更改。
在 EggTimer 對(duì)象中,使用.delegate 模式傳遞需要的數(shù)據(jù)。這一次(只是為了有點(diǎn)區(qū)別),你要在數(shù)據(jù)變化時(shí)廣播一個(gè) Notification??梢赃x擇任何對(duì)象來接收此通知,并在收到通知時(shí)進(jìn)行操作。
把下面的方法添加到 PrefsViewController 中:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
它會(huì)從自定義滑塊獲取數(shù)據(jù)(稍后以內(nèi)你會(huì)看到任何更改都反映在那里)。設(shè)置 selectedTime 屬性后將自動(dòng)將新數(shù)據(jù)保存到 UserDefaults。然后,名為 “PrefsChanged” 的通知將發(fā)布到 NotificationCenter。
稍后,你會(huì)看到如何將 ViewController 設(shè)置為監(jiān)聽此通知并對(duì)其作出反應(yīng)。
編寫 PrefsViewController 的最后一步是設(shè)置在第2部分中添加的 @IBActions 的代碼:
// 1
@IBAction func popupValueChanged(_ sender: NSPopUpButton) {
if sender.selectedItem?.title == "Custom" {
customSlider.isEnabled = true
return
}
let newTimerDuration = sender.selectedTag()
customSlider.integerValue = newTimerDuration
showSliderValueAsText()
customSlider.isEnabled = false
}
// 2
@IBAction func sliderValueChanged(_ sender: NSSlider) {
showSliderValueAsText()
}
// 3
@IBAction func cancelButtonClicked(_ sender: Any) {
view.window?.close()
}
// 4
@IBAction func okButtonClicked(_ sender: Any) {
saveNewPrefs()
view.window?.close()
}
- 從彈出窗口中選擇一個(gè)新項(xiàng)目時(shí),檢查它是否是自定義菜單項(xiàng)。如果是,啟用滑塊并退出。如果沒有,使用 tag 獲取分鐘數(shù),使用它們來設(shè)置滑塊值和文本,并禁用滑塊。
- 滑塊變動(dòng)時(shí)更新文字。
- 點(diǎn)擊 Cancel 會(huì)關(guān)閉窗口,且不保存改變。
- 點(diǎn)擊 OK 會(huì)先調(diào)用
saveNewPrefs然后關(guān)閉窗口。
現(xiàn)在構(gòu)建并運(yùn)行 app,然后轉(zhuǎn)到 Preferences。嘗試在彈出窗口中選擇不同的選項(xiàng)——注意滑塊和文本如何更改以匹配。選擇 Custom 并選擇自己的時(shí)間。單擊確定,然后返回 Preferences 并確認(rèn)仍然顯示你選擇的時(shí)間。
現(xiàn)在嘗試退出 app 并重新啟動(dòng)。返回 Preferences,可以看到它已儲(chǔ)存你的設(shè)定。
實(shí)現(xiàn)已選擇的偏好設(shè)置
Preferences 窗口看起來不錯(cuò)——按預(yù)期保存和還原了所選時(shí)間。但是當(dāng)你回到主窗口,仍然顯示一個(gè)6分鐘的蛋! :[
因此,需要編輯 ViewController.swift 以使用存儲(chǔ)的值進(jìn)行計(jì)時(shí),并監(jiān)聽更改通知,以便可以更改或重置計(jì)時(shí)器。
將此擴(kuò)展添加到 ViewController.swift,添加在任何現(xiàn)有類定義或擴(kuò)展之外——它將所有 preferences 相關(guān)功能分組到一個(gè)單獨(dú)的包中以使代碼更加整潔:
extension ViewController {
// MARK: - Preferences
func setupPrefs() {
updateDisplay(for: prefs.selectedTime)
let notificationName = Notification.Name(rawValue: "PrefsChanged")
NotificationCenter.default.addObserver(forName: notificationName,
object: nil, queue: nil) {
(notification) in
self.updateFromPrefs()
}
}
func updateFromPrefs() {
self.eggTimer.duration = self.prefs.selectedTime
self.resetButtonClicked(self)
}
}
這會(huì)導(dǎo)致錯(cuò)誤,因?yàn)?ViewController 沒有叫做 prefs 的對(duì)象。在 ViewController 類的主定義中,添加這行來定義 eggTimer 屬性:
var prefs = Preferences()
現(xiàn)在 PrefsViewController 有一個(gè) prefs 對(duì)象,ViewController也有一個(gè)——這是一個(gè)錯(cuò)誤嗎?不,有幾個(gè)原因。
-
Preferences是一個(gè)結(jié)構(gòu)體,因此它是基于值的,不是基于引用的。每個(gè)View Controller都有自己的副本。 -
Preferences結(jié)構(gòu)體通過單例與UserDefaults交互,因此兩個(gè)副本都使用相同的UserDefaults并獲取相同的數(shù)據(jù)。
在 ViewController viewDidLoad 函數(shù)的末尾,添加此調(diào)用用語設(shè)置Preferences 連接:
setupPrefs()
還最后一組編輯。之前是使用硬編碼值進(jìn)行計(jì)時(shí)——360 秒或 6 分鐘?,F(xiàn)在ViewController 有權(quán)訪問 Preferences,要把這些硬編碼的 360 秒的更改為 prefs.selectedTime。
在 ViewController.swift 里搜索 360 然后把給一個(gè)都改成 prefs.selectedTime——應(yīng)該能找到 3 個(gè)。
構(gòu)建并運(yùn)行 app。如果你之前更改了偏好的煮雞蛋時(shí)間,剩余時(shí)間將顯示你選擇的那個(gè)時(shí)間。打開 Preferences,選擇另一個(gè)時(shí)間,然后單擊確定——你的新時(shí)間將立即顯示出來,因?yàn)?ViewController 接收了通知。
啟動(dòng)計(jì)時(shí)器,然后打開 Preferences。倒計(jì)時(shí)在后面那個(gè)窗口繼續(xù)。更改雞蛋計(jì)時(shí),然后單擊確定。定時(shí)器應(yīng)用了新的時(shí)間,但停止并復(fù)位了計(jì)數(shù)器。其實(shí)這樣也可以,但如果 app 警告一下就會(huì)更好了。如何添加一個(gè)對(duì)話框,詢問這是否真的是你想做的嗎?
在ViewController 處理 Preferences 的 extension 中,添加此函數(shù):
func checkForResetAfterPrefsChange() {
if eggTimer.isStopped || eggTimer.isPaused {
// 1
updateFromPrefs()
} else {
// 2
let alert = NSAlert()
alert.messageText = "Reset timer with the new settings?"
alert.informativeText = "This will stop your current timer!"
alert.alertStyle = .warning
// 3
alert.addButton(withTitle: "Reset")
alert.addButton(withTitle: "Cancel")
// 4
let response = alert.runModal()
if response == NSAlertFirstButtonReturn {
self.updateFromPrefs()
}
}
}
上面發(fā)生了什么?
- 如果 timer 停止或暫停了,不用詢問直接弄。
- 創(chuàng)建一個(gè)
NSAlert,它是顯示對(duì)話框的類。配置其文本和樣式。 - 添加2個(gè)按鈕:Reset 和 Cancel。它們將按照從右到左的順序顯示,第一個(gè)將是默認(rèn)選項(xiàng)。
- 將 alert 顯示為模態(tài)對(duì)話框,然后等待答復(fù)。檢查用戶是否點(diǎn)擊第一個(gè)按鈕(復(fù)位),如果是這樣,重置定時(shí)器。
在 setupPrefs 方法中,將 self.updateFromPrefs() 行更改為:
self.checkForResetAfterPrefsChange()
構(gòu)建并運(yùn)行 app,啟動(dòng)計(jì)時(shí)器,打開 Preferences,更改時(shí)間,然后單擊確定。你會(huì)看到對(duì)話框,詢問是否重置。
聲音
這個(gè) app 目前為止唯一尚未涉及的就是聲音了。煮蛋器如果不能叮叮叮叮叮叮就不是煮蛋器了!
在第 2 部分中,已下載了 app 的資源文件夾。大多數(shù)是圖像,已經(jīng)用上了,但也有一個(gè)聲音文件:ding.mp3。如果你需要再次下載,這里是一個(gè)只有 聲音文件 的鏈接。
將 ding.mp3 文件拖動(dòng)到 Project Navigator 中 EggTimer 組內(nèi)——就在 Main.storyboard 下面,這似乎是一個(gè)合乎邏輯的地方。確保勾選 Copy items if needed ,并選中了 EggTimer target。然后單擊完成。
要播放聲音,需要使用 AVFoundation 庫。當(dāng) EggTimer 告訴它的 delegate 計(jì)時(shí)器已經(jīng)完成時(shí),ViewController 將播放聲音,所以打開 ViewController.swift。你會(huì)看到 Cocoa 庫在頂部被 import 了。
就在那行下面,添加這行:
import AVFoundation
ViewController 需要一個(gè)播放器來播放聲音文件,所以將它添加到屬性重:
var soundPlayer: AVAudioPlayer?
使 ViewController 有單獨(dú)的擴(kuò)展來保存聲音相關(guān)的功能好像是個(gè)好主意,所以添加如下代碼添加到 ViewController.swift,在任何現(xiàn)有的定義或 extension 之外:
extension ViewController {
// MARK: - Sound
func prepareSound() {
guard let audioFileUrl = Bundle.main.url(forResource: "ding",
withExtension: "mp3") else {
return
}
do {
soundPlayer = try AVAudioPlayer(contentsOf: audioFileUrl)
soundPlayer?.prepareToPlay()
} catch {
print("Sound player not available: \(error)")
}
}
func playSound() {
soundPlayer?.play()
}
}
prepareSound 做了這里的大部分工作——它首先檢查 ding.mp3 文件是否在 app bundle 中。如果文件存在,它嘗試用聲音文件 URL 初始化 AVAudioPlayer 并準(zhǔn)備播放。會(huì)預(yù)先緩沖聲音文件,以便在需要時(shí)立即播放。
playSound 只是發(fā)送一個(gè)播放消息給可能存在的播放器,但如果prepareSound 失敗了,soundPlayer 將是 nil 所以不會(huì)有任何事發(fā)生。
聲音只需要在點(diǎn)擊開始按鈕后準(zhǔn)備就緒就可以了,因此在 startButtonClicked 末尾插入此行:
prepareSound()
并在 eggTimerProtocol 擴(kuò)展中的 timerHasFinished 中添加:
playSound()
構(gòu)建和運(yùn)行 app,為你的蛋選擇一個(gè)短一點(diǎn)的時(shí)間,啟動(dòng)計(jì)時(shí)器。當(dāng)定時(shí)器結(jié)束時(shí),你聽到叮了嗎?
下一步?
你可以在這里下載 完整項(xiàng)目 。
本 macOS 系列開發(fā)教程為你介紹了基本的知識(shí)以開始開發(fā) macOS app,但還有很多要學(xué)習(xí)!
蘋果有一些特別棒的 文檔 ,涵蓋了 macOS 開發(fā)的所有方面。
我還強(qiáng)烈建議看看其他在 raywenderlich.com 的 macOS 教程。
如果您有任何問題或意見,請(qǐng)?jiān)谙旅嬖u(píng)論!