本文翻譯自 raywenderlich.com 的 macOS 開發經典入門教程 ,已咨詢對方網站,可至多翻譯 10 篇文章。
希望各位有英語閱讀能力的話,還是 先打賞 然后去閱讀英文原吧,畢竟無論是 Xcode,抑或是官方的文檔,還是各種最前沿的資訊都只有英文版本。
綜上,此翻譯版本僅供參考,謝絕轉載。
相關鏈接
零基礎 macOS 應用開發(一): 原文 / 譯文
零基礎 macOS 應用開發(二): 原文 / 譯文
零基礎 macOS 應用開發(三): 原文 / 譯文(本文)
歡迎回到我們的零基礎 macOS 應用開發教程的最后一部分(共三部分)!
在第一部分中,你已經學會了如何安裝 Xcode 和如何創建一個示例 app;在第二部分中你為一個更加復雜的 app 創建了 UI,但因為你還沒有編寫任何代碼,所以它還不能工作。在這個部分中,你將會編寫所有 Swift 代碼并讓你的 app 真正活起來!
開始
如果你還沒有完成第二部分,或你希望從一個更加純凈的情況繼續學習,你可以下載第二部分中已經完成了 UI 布局的工程文件。打開你下載的或你跟著第二部分完成的工程文件,并運行一下它,確認一下是否所有的 UI 都能正確顯示,打開偏好設置窗口看看它是否能正常顯示。
沙盒機制
在你開始編寫代碼之前,請花一些時間來了解一下 macOS 的沙盒機制。如果你是一個 iOS 開發者,你已經了解了這個概念,如果你不曾了解過,繼續往下閱讀。
一個沙盒化了的 app 擁有自己獨立的存儲空間,沙盒會禁止你的 app 訪問另一個 app 創建的文件以及其他的許可和限制。對于 iOS app,使用沙盒是必須的,而對于 macOS app,這只是一個可選項;但如果你希望通過 Mac App Store 進行分發和銷售,你的 app 必須沙盒化,由于沙盒帶來的諸多限制,你的 app 可能會出現一些問題。
要為你的 app 啟用沙盒,在 Project Navigator(項目導航器)中選擇項目文件,也就是文件列表里最頂上的藍色圖標。在 Targets 列表中選擇 EggTimer(其實 Targets 列表里也只有一個項目可以選擇),然后在上方的標簽中點擊 Capabilities(功能)標簽,點擊 App Sandbox(應用沙盒)那一欄的開關,這個視圖將會展開并顯示你的 app 可以申請的許多權限。這個例子中的 app 不需要任何特殊的權限,因此它們都不需要打開。
管理你的文件
看一眼你的 Project Navigator(項目導航器),所有的文件都堆在一起,缺乏組織,這個 app 不會有很多文件,但把文件整理的井井有條始終都會是個好習慣,也能幫助我們更快速地定位到你需要的文件,這一點對于大型項目尤其有用。
按住 Shift 的同時分別點擊兩個 View Controller 文件,把他們同時選中,右鍵點擊并選擇 New Group from selection(用所選項目創建新的分組),給新建的分組起名為 View Controllers。
這個項目將會包含一些 Model 文件,所以右鍵點擊 EggTimer 分組,選擇 New Group(新建分組),把這個分組命名為 Model。
最后,選中 Info.plist 和 EggTimer.entitlements,把它們扔掉一個叫 Supporting Files 的文件夾里。
拖動分組和文件調整他們的順序,直到你的項目看起來像這樣:
MVC
這個 app 將會應用 MVC 模式:Model View Controller(模型 - 視圖 - 控制器)。
譯者注:請參見 MVC 設計模式的維基百科詞條,以及這篇簡書文章。
以及下文會經常出現的名詞,下文就不再翻譯啦~
Model:模型
View:視圖
Controller:控制器
Delegate and Protocol:代理與協議
我們要給 app 創建的第一個 Model 對象名叫 EggTimer
。這個類將會擁有一些關于計時器的開始時間、倒計時的時長和以及過去的時間的屬性。還有一個叫做 Timer
的對象,每過一秒它都會被激活,并更新自己的狀態,并用自己的方法來開始、暫停、恢復或把 EggTimer
歸零。
EggTimer
Model 類還會保存數據并執行動作,但它不能用來顯示數據。Controller(在這個項目中就是 ViewController
)則能與 EggTimer
(也就是 Model)通信,它擁有一個 View
并用它來顯示數據。
為了能和 ViewController
通信,EggTimer
使用一個代理協議(Delegate Protocol),每當某些數據發生改變時,EggTimer
向它的 delegate
發送一條消息,ViewController
則讓自己去擔任 EggTimer
的這個所謂的 delegate
,所以它能接收到這條消息,并把新的數據顯示在界面上。
編寫 EggTimer 類
在項目導航器中選中 Model 分組,并點擊 Xcode 菜單欄上的 File → New → File…,選擇 macOS → Swift File,并點擊 Next,給這個文件起名為 EggTimer.swift 并點擊 Create 來創建它。
在這個文件中加入以下代碼:
class EggTimer {
var timer: Timer? = nil
var startTime: Date?
var duration: TimeInterval = 360 // 默認的計時時間是 6 分鐘
var elapsedTime: TimeInterval = 0
}
這樣 EggTimer
類和它的屬性們就設置好了。TimeInterval
其實就是 Double
類型,但一般我們在表示秒數時都會使用它而不是 Double。
第二件事是在類中添加兩個計算屬性(Computed Properties),這兩個屬性是用來決定 EggTimer
屬性的捷徑。將以下代碼寫在剛剛添加的屬性之后:
var isStopped: Bool {
return timer == nil && elapsedTime == 0
}
var isPaused: Bool {
return timer == nil && elapsedTime > 0
}
在 EggTimer.swift 文件 EggTimer
類以外的地方添加代理協議的定義 —— 我更喜歡把代理協議寫在文件頂部 import 部分的后邊。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
你可以理解為:這個協議制定了一份合同,任何宣布遵守 EggTimerProtocol
協議(也就是簽訂了這份合同)的對象都需要實現這兩個方法。
現在你定義了一個協議,EggTimer
可以通過定義一個 delegate
(代理)屬性來履行這份協議,這個屬性的類型可以是任何類型(Any)。EggTimer
并不知道也不關心代理的類型是什么,因為很明顯既然這個代理源自 EggTimerProtocol
協議,它擁有這兩個方法。
將這些代碼屬性添加到 EggTimer
類:
var delegate: EggTimerProtocol?
讓 EggTimer
的 timer 對象開始運行會導致一個方法每秒鐘被調用一次,繼續添加以下代碼來定義這個方法,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)
}
}
…所以這些代碼到底是在做些什么?
-
startTime
是個可選的Date
,當它是nil
時,timer 將無法運行,所以這時什么都不會發生; - 重新計算
elapsedTime
屬性,startTime
比當前的時間還要早,所以 timeIntervalSinceNow 會產生一個負值,這個負值會使得elapsedTime
成為一個正值; - 計算 timer 的剩余時間,并進行取整;
- 如果 timer 已經結束,就把它重設,并告知
delegate
計時結束了;否則,告訴delegate
計時器還剩多少秒。另外,由于delegate
是一個可選值,所以需要用?
來進行解包,也就是說,如果delegate
還沒有被賦值,除了那些方法不會被調用,沒有別的壞事會發生。
你會看到 Xcode 提示我們出現了一些錯誤,不過當我們完成了 EggTimer
類的代碼之后,它們就會消失了,這是因為我們還沒有添加用于開始計時、暫停計時、恢復計時和重啟計時器的方法。
// 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() {
// 停止計時器 & 重設所有屬性
timer?.invalidate()
timer = nil
startTime = nil
duration = 360
elapsedTime = 0
timerAction()
}
這些代碼是做什么的?
- 通過調用
Date()
方法startTimer
設置開始時間為當前時間,然后它會設置一個一直重復運行的Timer
; -
resumeTimer
是計時器已經暫停并需要繼續時會被調用的方法,它還會根據已經過去的時間重新設置開始時間; -
stopTimer
會停止重復運行的 timer; -
resetTimer
會停止 timer,并把相關屬性恢復原始設置。
以上的這些方法都會調用 timerAction
,所以一旦它們被調用,界面上顯示的內容都會被更新。
ViewController
現在 EggTimer
對象已經業已正常運轉了,我們該回到 ViewController.swift 中讓數據的變化能及時反映到界面上了。
ViewController
已經擁有了 @IBOutlet
屬性,但現在你需要讓它擁有一個類型為 EggTimer
的屬性:
var eggTimer = EggTimer()
將 viewDidLoad
方法中的注釋行替換成這一行:
eggTimer.delegate = self
寫完上面的代碼以后會出現一個錯誤,因為 ViewController
還沒有遵從 EggTimerProtocol
協議。當我們要讓一個類遵從某個協議時,如果我們單獨創建一個 Extension(擴展)來盛放協議需要的方法,你的代碼將會看起來整潔許多。在 ViewController
類以外的地方輸入以下代碼:
extension ViewController: EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
updateDisplay(for: timeRemaining)
}
func timerHasFinished(_ timer: EggTimer) {
updateDisplay(for: 0)
}
}
因此我們還需要為 ViewController
添加另一個 Extension,用來盛放關于屏幕顯示的方法。
extension ViewController {
// MARK: - 顯示
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
使用一個 Private 方法來根據剩余的時間來獲取文本和圖像,并將它們顯示在界面上的 Text Field 和 Image View 中。
textToDisplay
把剩余的時間格式化成「分:秒」的格式。imageToDisplay
計算出雞蛋有多熟的百分比,然后選擇合適的圖片來顯示在界面上。
所以 ViewController
用一個 EggTimer
對象的方法來接收 EggTimer
傳來的數據并顯示在屏幕上,但是界面上的按鈕還沒有任何實質性的代碼。在第二部分中,你已經為按鈕設置了 @IBAction
。
這里是這些 IBAction 的方法,你可以用它們來替代之前的 IBAction。
@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)
}
這里的三個 IBAction 將會調用你之前添加的 EggTimer
方法。
現在編譯并運行你的 app,并點擊 Start 按鈕。你還可以用 Timer 菜單來控制這個 app,試著去用鍵盤快捷鍵來操作你的 app。
現在我們還需要完善一些功能:Stop 和 Reset 按鈕始終是被禁用的,而且你只可以定 6 分鐘的時。
如果你有足夠的耐心,你將會看到雞蛋的顏色隨著時間漸漸改變,并在完成時顯示一個「DONE!」。
按鈕和菜單
界面上的按鈕以及菜單里的菜單項應該隨著 timer 的狀態自動啟用或禁用。
把這個方法添加到 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)
}
}
這個方法使用 EggTimer
的狀態(還記得你添加到 EggTimer
里的計算屬性嗎)來計算出哪個按鈕應該啟用。
在第二部分中,你創立了一個 Timer menu item 作為 AppDelegate
的屬性,所以我們應該在 AppDelegate
中來編輯這些代碼。
切換到 AppDelegate.swift,在其中添加這個方法:
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
為了讓你的你的 app 能在初次啟動時自動配置按鈕的啟用狀態,在 applicationDidFinishLaunching
方法中添加這些代碼:
enableMenus(start: true, stop: false, reset: false)
每當用戶按下了任何一個按鈕或菜單項的時候,EggTimer
的狀態會發生改變,按鈕或菜單項的狀態也需要隨之更新。返回到 ViewController.swift 中并把這一行添加到三個按鈕的 IBAction 方法中:
configureButtonsAndMenus()
再次編譯并運行你的 app,你可以看到按鈕們如預期地啟用和禁用了。點擊菜單里的菜單項試試,它們應該擁有和按鈕一樣的功能。
偏好設置窗口
這個 app 還有一個很重要的問題:如果你希望煮雞蛋的時間不是 6 分鐘呢?
在第二部分中,你已經設計好了一個偏好設置窗口來允許用戶來選擇需要的倒計時時間,這個窗口是由 PrefsViewController
控制的,但它還需要一個 Model 對象來處理和查詢數據。
用戶的設置可以通過一個叫 UserDefaults
的東西來存儲,它會在你 app 的沙盒容器中的 Preferences 文件夾中用鍵值對來存儲零碎的小數據。
在 Project Navigator(項目導航器) 中,右鍵點擊 Model 分組,并選擇 Xcode 菜單上的 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
的TimeInterval
計算屬性; - 當別的代碼請求訪問這個變量的值的時候時,
UserDefaults
的單例將會去查找鍵「selectedTime」對應的Double
值;如果這個值從沒被定義過,UserDefaults
將會返回 0;但如果存在這個值,且它大于 0,就將這個值返回,并設置為selectedTime
; - 如果
selectedTime
還沒有被定義過,就使用默認值 360(6 分鐘); - 只要
selectedTime
的值發生了改變,把新的值用鍵「selectedTime」存入UserDefaults
。
通過使用 getter 和 setter,UserDefaults
的數據存儲將能夠自動進行。
現在切換回 PrefsViewController.swift,我們需要把用戶修改的設置內容在界面上顯示出來。
第一步,在 IBOutlet 之下添加這些代碼:
var prefs = Preferences()
這一步中你創建了一個 Preferences
的實例,所以你現在可以自由訪問 selectedTime
計算變量了。
接下來,添加這些方法:
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)"
}
好像是很大一坨代碼???…所以我們一點一點來看:
- 訪問
prefs
對象的selectedTime
屬性,并把它轉化成整數的分鐘數; - 把默認的計時時間設置為「Custom」,以防止沒有找到人寰預設的數據;
- 遍歷
presetsPopup
里的菜單項并檢查他們的 tag,還記得在第二部分中你把每個項目的 tag 都設置成了各自選項的分鐘數了嗎?如果找到了用戶選擇的菜單項,就把這個菜單項啟用,并跳出這個循環; - 設置滑動條的數值,并調用
showSliderValueAsText
方法; -
showSliderValueAsText
把數字加上「minute」或「minutes」并將它顯示在界面上的 Text Field 中。
現在,把這行代碼添加到 viewDidLoad
中:
showExistingPrefs()
在 View 加載的時候,會調用這個方法,把用戶的設置加載到界面上,在 MVC 模式中,Preferences
Model 完全不知道它佇立的數據會怎樣被顯示出來 —— 界面顯示是 PrefsViewController
的事兒。
所以,盡管現在你的 app 已經可以顯示用戶設置的時間了,然而偏好設置里的下拉框還是不能工作,你需要為它編寫一個方法來讓它能存儲新的的設置,并告訴所有相關對象數據發生了改變。
在 EggTimer
對象中,你使用了 delegate 模式來把數據傳遞到需要它的地方,這一次,你需要通過發送一個 Notification
(通知)來告訴大家數據改變了(其實用 delegate 還是可以的,這里只是為了演示 Notification 的用法)。任何對象在表明自己對這個通知感興趣之后,都可以接收到這個通知,并在接收時采取行動。
在 PrefsViewController
中添加以下方法:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
這個方法將會獲取 customSlider 滑動條的數值,并轉化成分鐘數,賦值予 selectedTime
,因為我們之前編寫的 setter,它會自動使用 UserDefaults
來存儲新的數據。然后 NotificationCenter
(通知中心)會將一個名叫「PrefsChanged」通知發送出去。
接下來,我們來讓 ViewController
能夠接收到這個 Notification
,并采取行動:
在 PrefsViewController
中要編寫的最后一部分代碼是為第二部分中你添加的 @IBAction
們添加真正的代碼:
// 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()
}
- 當用戶在下拉框中選擇了一個新的菜單項,這段代碼會檢測這個項是不是 Custom:
- 如果是的,就啟用滑動條,并直接終止這個方法;
- 如果不是,就通過這個項的 tag 來獲取用戶選擇的計時時間;
- 每當滑動條的數據更新時,更新界面上的文本;
- 點擊 Cancel 按鈕會把窗口關閉,且不會存儲數據;
- 點擊 OK 按鈕會先調用
saveNewPrefs
,然后關閉這個窗口。
編譯并運行你的 app,前往 Preferences,試著在下拉框中選擇不同的選項,觀察一下滑動條和文本有沒有根據你的選擇而正確顯示。選擇 Custom 選項,然后自己選擇一個時間,點擊 OK,然后再次前往 Preferences,看看你剛剛選擇的時間是不是還能正常顯示。
現在試著退出你的 app 并重新打開它,返回 Preferences,看看你的 app 是否保存了你的設置。
讓用戶的設置生效
現在偏好設置窗口看起來還不錯了 —— 它可以存儲并讀取用戶的設置,但當你回到主窗口,你看到的時間會還是 6 分鐘! ??
所以你需要編輯 ViewController.swift,讓它能使用存儲了的數據,并偵聽關于數據變化了的通知,從而及時更新或重設 Timer。
把這個 Extension 添加到 ViewController.swift 中類定義以外的部分 —— 這樣一來我們的代碼會被分成若干個承擔不同職能的部分,看起來會更整潔。
extension ViewController {
// MARK: - 設置
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)
}
}
這些代碼會報錯,因為 ViewController
內部還沒有一個叫做 prefs
的對象。在 ViewController
類的定義中(也就是你定義 eggTimer
的地方),添加這行代碼:
var prefs = Preferences()
現在 PrefsViewController
和 ViewController
內部都有了一個 prefs 屬性 —— 這是個問題嗎?不!原因如下:
-
Preferences
是一個 struct(結構體),所以它是一個數據型的對象而非一個關系型的對象。每一個 View Controller 都可以擁有一份它的副本; -
Preferences
結構體是使用了UserDefaults
的單例,所以這倆副本其實是在調用同一個UserDefaults
,因此拿到的數據也是完全一樣的。
在 ViewController 最后的 viewDidLoad
方法中,添加這一行代碼,它會設置好自己和 Preferences
的連接:
setupPrefs()
現在還有最后的一系列步驟需要做。之前我們把默認的時間,也就是 360 秒,直接寫進了代碼里(也就是硬編碼,hard-coded),現在因為 ViewController
已經可以訪問 Preferences
了,你需要修改一下這種寫法。
在 ViewController.swift 中找到「360」(你應該能找到 3 個 360),并把它們修改成 prefs.selectedTime
。
編譯并運行你的 app,如果你之前修改過設置里的計時時間,你選擇的時間現在應該能正常顯示在界面上了。前往 Preferences,選擇另一時間,點擊 OK —— 因為 ViewController
接收到了通知,你新選擇的時間應該馬上就能顯示出來了。
啟動計時器,然后前往 Preferences,在主窗口中,倒計時還在繼續,修改一個時間然后點擊 OK,計時器應用了新的時間,但是也停止并重設了倒計時。我覺得這沒什么問題,但是如果能添加一個提示,詢問用戶是否真的希望停止計時,這樣會不會更好呢?
在 ViewController 中負責處理設置的 Extension 中,添加這些代碼:
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()
}
}
}
所以這些代碼是干啥的?
- 如果計時器已經停止或暫停了,不做任何操作直接修改時間;
- 創建一個
NSAlert
,它是一個用來顯示一個對話框的類,并設置它的文字和樣子; - 添加兩個按鈕:Reset 和 Cancel,它們將會根據你添加的順序從右往左顯示在對話框中,且右邊的將會是默認選項;
- 把警告以一個模態的窗口顯示出來,并等待用戶的選擇,如果用戶點擊了第一個按鈕(Reset),就重設計時器。
在 setupPrefs
方法中,把 self.updateFromPrefs()
這一行改成:
self.checkForResetAfterPrefsChange()
編譯并運行你的 app,開始計時,前往 Preferences,修改一下時間,然后點擊 OK,你將會看見一個對話框詢問你是否要重設時間。
音效
現在這個 app 中唯一未完成的功能就是音效了。如果沒有「叮~~」的一聲的話,煮蛋計時器還能叫做煮蛋計時器嗎?
在第二部分中,你已經下載了一個包含了所有資產的文件夾,其中的內容絕大多數都是圖片,你也已經用過它們了,但是其實這里面還有一個音效文件:ding.mp3。如果你找不到這個文件了,你可以單獨下載這個音效文件。
把 ding.mp3 拖動到 Project Navigator(項目導航器)中的 EggTimer 分組下方 —— 看起來就放在 Main.storyboard 下邊是一個不錯的想法。勾選 Copy items if needed(如果需要的話把文件拷貝到項目中),在 Add to targets(添加到目標中) 中勾選 EggTimer,然后點擊 Finish。
你需要一個叫 AVFoundation
的庫來播放聲音。當代理告訴 ViewController
計時器結束了的時候,ViewController
就會負責播放這個音效,所以我們切換到 ViewController.swift 中,在最頂部你會看到這個文件引用了 Cocoa
庫(import Cocoa
)。
在那一行引用的下方,添加:
import AVFoundation
ViewController
需用一個 AVAudioPlayer
來播放聲音,所以我們為它添加一個屬性:
var soundPlayer: AVAudioPlayer?
我們應該為 ViewController
新建一個單獨的 Extension 來處理和聲音相關的方法,所以在 ViewController.swift 類定義以外的地方添加:
extension ViewController {
// MARK: - 聲音
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 的包中,如果這個文件存在,它就會試圖去用這個文件的 URL 來實例化一個 AVAudioPlayer
,并準備好它以備播放。這將會預先加載這個音頻文件,所以一旦需要,就可以立即播放。
如果 soundPlayer
存在,playSound
會調用它的 play()
方法;但如果 prepareSound
運行失敗了,soundPlayer
將會為空(nil),因此它什么也不會做。
聲音文件只在 Start 按鈕被點擊時需要被準備,所以把這行代碼插入到 startButtonClicked
方法的最后:
prepareSound()
在 EggTimerProtocol Extension 的 timerHasFinished 方法中,追加這行代碼:
playSound()
編譯并運行之,選擇一個短一點的時間并開始計時,一聲清脆的「叮??」會在計時結束的時候響起。
現在該做些什么?
你可以下載這個項目的源代碼。
在這個 macOS 開發教程中,你已經掌握了開發 macOS app 的基本技能,但真正要學習的還有很多!
Apple 編寫了許多很棒的文檔,他們覆蓋了 macOS 開發的方方面面。
我同時強烈建議你去看看我們(原作者)的網站 raywenderlich.com 上的其他 macOS 教程。
如果你還有任何問題,歡迎在原文下方參與討論!