本文翻譯自 raywenderlich.com 的 macOS 開發(fā)經(jīng)典入門教程 训枢,已咨詢對方網(wǎng)站,可至多翻譯 10 篇文章。
希望各位有英語閱讀能力的話,還是 先打賞 然后去閱讀英文原吧使碾,畢竟無論是 Xcode蜜徽,抑或是官方的文檔,還是各種最前沿的資訊都只有英文版本票摇。
綜上拘鞋,此翻譯版本僅供參考,謝絕轉(zhuǎn)載兄朋。
相關(guān)鏈接
零基礎(chǔ) macOS 應(yīng)用開發(fā)(一): 原文 / 譯文
零基礎(chǔ) macOS 應(yīng)用開發(fā)(二): 原文 / 譯文
零基礎(chǔ) macOS 應(yīng)用開發(fā)(三): 原文 / 譯文(本文)
歡迎回到我們的零基礎(chǔ) macOS 應(yīng)用開發(fā)教程的最后一部分(共三部分)掐禁!
在第一部分中怜械,你已經(jīng)學(xué)會了如何安裝 Xcode 和如何創(chuàng)建一個(gè)示例 app颅和;在第二部分中你為一個(gè)更加復(fù)雜的 app 創(chuàng)建了 UI,但因?yàn)槟氵€沒有編寫任何代碼缕允,所以它還不能工作峡扩。在這個(gè)部分中,你將會編寫所有 Swift 代碼并讓你的 app 真正活起來障本!
開始
如果你還沒有完成第二部分教届,或你希望從一個(gè)更加純凈的情況繼續(xù)學(xué)習(xí),你可以下載第二部分中已經(jīng)完成了 UI 布局的工程文件驾霜。打開你下載的或你跟著第二部分完成的工程文件案训,并運(yùn)行一下它,確認(rèn)一下是否所有的 UI 都能正確顯示粪糙,打開偏好設(shè)置窗口看看它是否能正常顯示强霎。
沙盒機(jī)制
在你開始編寫代碼之前,請花一些時(shí)間來了解一下 macOS 的沙盒機(jī)制蓉冈。如果你是一個(gè) iOS 開發(fā)者城舞,你已經(jīng)了解了這個(gè)概念,如果你不曾了解過寞酿,繼續(xù)往下閱讀家夺。
一個(gè)沙盒化了的 app 擁有自己獨(dú)立的存儲空間,沙盒會禁止你的 app 訪問另一個(gè) app 創(chuàng)建的文件以及其他的許可和限制伐弹。對于 iOS app拉馋,使用沙盒是必須的,而對于 macOS app惨好,這只是一個(gè)可選項(xiàng)煌茴;但如果你希望通過 Mac App Store 進(jìn)行分發(fā)和銷售,你的 app 必須沙盒化昧狮,由于沙盒帶來的諸多限制景馁,你的 app 可能會出現(xiàn)一些問題。
要為你的 app 啟用沙盒逗鸣,在 Project Navigator(項(xiàng)目導(dǎo)航器)中選擇項(xiàng)目文件漏策,也就是文件列表里最頂上的藍(lán)色圖標(biāo)旨袒。在 Targets 列表中選擇 EggTimer(其實(shí) Targets 列表里也只有一個(gè)項(xiàng)目可以選擇)扛点,然后在上方的標(biāo)簽中點(diǎn)擊 Capabilities(功能)標(biāo)簽,點(diǎn)擊 App Sandbox(應(yīng)用沙盒)那一欄的開關(guān)笨使,這個(gè)視圖將會展開并顯示你的 app 可以申請的許多權(quán)限。這個(gè)例子中的 app 不需要任何特殊的權(quán)限僚害,因此它們都不需要打開硫椰。
管理你的文件
看一眼你的 Project Navigator(項(xiàng)目導(dǎo)航器),所有的文件都堆在一起萨蚕,缺乏組織靶草,這個(gè) app 不會有很多文件,但把文件整理的井井有條始終都會是個(gè)好習(xí)慣岳遥,也能幫助我們更快速地定位到你需要的文件奕翔,這一點(diǎn)對于大型項(xiàng)目尤其有用。
按住 Shift 的同時(shí)分別點(diǎn)擊兩個(gè) View Controller 文件浩蓉,把他們同時(shí)選中派继,右鍵點(diǎn)擊并選擇 New Group from selection(用所選項(xiàng)目創(chuàng)建新的分組),給新建的分組起名為 View Controllers捻艳。
這個(gè)項(xiàng)目將會包含一些 Model 文件驾窟,所以右鍵點(diǎn)擊 EggTimer 分組,選擇 New Group(新建分組)认轨,把這個(gè)分組命名為 Model绅络。
最后,選中 Info.plist 和 EggTimer.entitlements好渠,把它們?nèi)拥粢粋€(gè)叫 Supporting Files 的文件夾里昨稼。
拖動分組和文件調(diào)整他們的順序,直到你的項(xiàng)目看起來像這樣:
MVC
這個(gè) app 將會應(yīng)用 MVC 模式:Model View Controller(模型 - 視圖 - 控制器)拳锚。
譯者注:請參見 MVC 設(shè)計(jì)模式的維基百科詞條假栓,以及這篇簡書文章。
以及下文會經(jīng)常出現(xiàn)的名詞霍掺,下文就不再翻譯啦~
Model:模型
View:視圖
Controller:控制器
Delegate and Protocol:代理與協(xié)議
我們要給 app 創(chuàng)建的第一個(gè) Model 對象名叫 EggTimer
匾荆。這個(gè)類將會擁有一些關(guān)于計(jì)時(shí)器的開始時(shí)間、倒計(jì)時(shí)的時(shí)長和以及過去的時(shí)間的屬性杆烁。還有一個(gè)叫做 Timer
的對象牙丽,每過一秒它都會被激活,并更新自己的狀態(tài)兔魂,并用自己的方法來開始烤芦、暫停、恢復(fù)或把 EggTimer
歸零析校。
EggTimer
Model 類還會保存數(shù)據(jù)并執(zhí)行動作构罗,但它不能用來顯示數(shù)據(jù)铜涉。Controller(在這個(gè)項(xiàng)目中就是 ViewController
)則能與 EggTimer
(也就是 Model)通信,它擁有一個(gè) View
并用它來顯示數(shù)據(jù)遂唧。
為了能和 ViewController
通信芙代,EggTimer
使用一個(gè)代理協(xié)議(Delegate Protocol),每當(dāng)某些數(shù)據(jù)發(fā)生改變時(shí)盖彭,EggTimer
向它的 delegate
發(fā)送一條消息纹烹,ViewController
則讓自己去擔(dān)任 EggTimer
的這個(gè)所謂的 delegate
,所以它能接收到這條消息召边,并把新的數(shù)據(jù)顯示在界面上铺呵。
編寫 EggTimer 類
在項(xiàng)目導(dǎo)航器中選中 Model 分組,并點(diǎn)擊 Xcode 菜單欄上的 File → New → File…掌实,選擇 macOS → Swift File陪蜻,并點(diǎn)擊 Next,給這個(gè)文件起名為 EggTimer.swift 并點(diǎn)擊 Create 來創(chuàng)建它贱鼻。
在這個(gè)文件中加入以下代碼:
class EggTimer {
var timer: Timer? = nil
var startTime: Date?
var duration: TimeInterval = 360 // 默認(rèn)的計(jì)時(shí)時(shí)間是 6 分鐘
var elapsedTime: TimeInterval = 0
}
這樣 EggTimer
類和它的屬性們就設(shè)置好了。TimeInterval
其實(shí)就是 Double
類型滋将,但一般我們在表示秒數(shù)時(shí)都會使用它而不是 Double邻悬。
第二件事是在類中添加兩個(gè)計(jì)算屬性(Computed Properties),這兩個(gè)屬性是用來決定 EggTimer
屬性的捷徑随闽。將以下代碼寫在剛剛添加的屬性之后:
var isStopped: Bool {
return timer == nil && elapsedTime == 0
}
var isPaused: Bool {
return timer == nil && elapsedTime > 0
}
在 EggTimer.swift 文件 EggTimer
類以外的地方添加代理協(xié)議的定義 —— 我更喜歡把代理協(xié)議寫在文件頂部 import 部分的后邊父丰。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
你可以理解為:這個(gè)協(xié)議制定了一份合同,任何宣布遵守 EggTimerProtocol
協(xié)議(也就是簽訂了這份合同)的對象都需要實(shí)現(xiàn)這兩個(gè)方法掘宪。
現(xiàn)在你定義了一個(gè)協(xié)議蛾扇,EggTimer
可以通過定義一個(gè) delegate
(代理)屬性來履行這份協(xié)議,這個(gè)屬性的類型可以是任何類型(Any)魏滚。EggTimer
并不知道也不關(guān)心代理的類型是什么镀首,因?yàn)楹苊黠@既然這個(gè)代理源自 EggTimerProtocol
協(xié)議,它擁有這兩個(gè)方法鼠次。
將這些代碼屬性添加到 EggTimer
類:
var delegate: EggTimerProtocol?
讓 EggTimer
的 timer 對象開始運(yùn)行會導(dǎo)致一個(gè)方法每秒鐘被調(diào)用一次更哄,繼續(xù)添加以下代碼來定義這個(gè)方法,dynamic
關(guān)鍵字是讓 Timer
能發(fā)現(xiàn)它的關(guān)鍵腥寇。
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
是個(gè)可選的Date
,當(dāng)它是nil
時(shí)赦役,timer 將無法運(yùn)行麻敌,所以這時(shí)什么都不會發(fā)生; - 重新計(jì)算
elapsedTime
屬性掂摔,startTime
比當(dāng)前的時(shí)間還要早术羔,所以 timeIntervalSinceNow 會產(chǎn)生一個(gè)負(fù)值职辅,這個(gè)負(fù)值會使得elapsedTime
成為一個(gè)正值; - 計(jì)算 timer 的剩余時(shí)間聂示,并進(jìn)行取整域携;
- 如果 timer 已經(jīng)結(jié)束,就把它重設(shè)鱼喉,并告知
delegate
計(jì)時(shí)結(jié)束了秀鞭;否則,告訴delegate
計(jì)時(shí)器還剩多少秒扛禽。另外锋边,由于delegate
是一個(gè)可選值,所以需要用?
來進(jìn)行解包编曼,也就是說豆巨,如果delegate
還沒有被賦值,除了那些方法不會被調(diào)用掐场,沒有別的壞事會發(fā)生往扔。
你會看到 Xcode 提示我們出現(xiàn)了一些錯誤,不過當(dāng)我們完成了 EggTimer
類的代碼之后熊户,它們就會消失了萍膛,這是因?yàn)槲覀冞€沒有添加用于開始計(jì)時(shí)、暫停計(jì)時(shí)嚷堡、恢復(fù)計(jì)時(shí)和重啟計(jì)時(shí)器的方法蝗罗。
// 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() {
// 停止計(jì)時(shí)器 & 重設(shè)所有屬性
timer?.invalidate()
timer = nil
startTime = nil
duration = 360
elapsedTime = 0
timerAction()
}
這些代碼是做什么的?
- 通過調(diào)用
Date()
方法startTimer
設(shè)置開始時(shí)間為當(dāng)前時(shí)間蝌戒,然后它會設(shè)置一個(gè)一直重復(fù)運(yùn)行的Timer
串塑; -
resumeTimer
是計(jì)時(shí)器已經(jīng)暫停并需要繼續(xù)時(shí)會被調(diào)用的方法,它還會根據(jù)已經(jīng)過去的時(shí)間重新設(shè)置開始時(shí)間北苟; -
stopTimer
會停止重復(fù)運(yùn)行的 timer桩匪; -
resetTimer
會停止 timer,并把相關(guān)屬性恢復(fù)原始設(shè)置粹淋。
以上的這些方法都會調(diào)用 timerAction
吸祟,所以一旦它們被調(diào)用,界面上顯示的內(nèi)容都會被更新桃移。
ViewController
現(xiàn)在 EggTimer
對象已經(jīng)業(yè)已正常運(yùn)轉(zhuǎn)了屋匕,我們該回到 ViewController.swift 中讓數(shù)據(jù)的變化能及時(shí)反映到界面上了。
ViewController
已經(jīng)擁有了 @IBOutlet
屬性借杰,但現(xiàn)在你需要讓它擁有一個(gè)類型為 EggTimer
的屬性:
var eggTimer = EggTimer()
將 viewDidLoad
方法中的注釋行替換成這一行:
eggTimer.delegate = self
寫完上面的代碼以后會出現(xiàn)一個(gè)錯誤过吻,因?yàn)?ViewController
還沒有遵從 EggTimerProtocol
協(xié)議。當(dāng)我們要讓一個(gè)類遵從某個(gè)協(xié)議時(shí),如果我們單獨(dú)創(chuàng)建一個(gè) Extension(擴(kuò)展)來盛放協(xié)議需要的方法纤虽,你的代碼將會看起來整潔許多乳绕。在 ViewController
類以外的地方輸入以下代碼:
extension ViewController: EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
updateDisplay(for: timeRemaining)
}
func timerHasFinished(_ timer: EggTimer) {
updateDisplay(for: 0)
}
}
因此我們還需要為 ViewController
添加另一個(gè) Extension,用來盛放關(guān)于屏幕顯示的方法逼纸。
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
使用一個(gè) Private 方法來根據(jù)剩余的時(shí)間來獲取文本和圖像洋措,并將它們顯示在界面上的 Text Field 和 Image View 中。
textToDisplay
把剩余的時(shí)間格式化成「分:秒」的格式杰刽。imageToDisplay
計(jì)算出雞蛋有多熟的百分比菠发,然后選擇合適的圖片來顯示在界面上。
所以 ViewController
用一個(gè) EggTimer
對象的方法來接收 EggTimer
傳來的數(shù)據(jù)并顯示在屏幕上贺嫂,但是界面上的按鈕還沒有任何實(shí)質(zhì)性的代碼滓鸠。在第二部分中,你已經(jīng)為按鈕設(shè)置了 @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)
}
這里的三個(gè) IBAction 將會調(diào)用你之前添加的 EggTimer
方法曲饱。
現(xiàn)在編譯并運(yùn)行你的 app悠抹,并點(diǎn)擊 Start 按鈕。你還可以用 Timer 菜單來控制這個(gè) app渔工,試著去用鍵盤快捷鍵來操作你的 app锌钮。
現(xiàn)在我們還需要完善一些功能:Stop 和 Reset 按鈕始終是被禁用的,而且你只可以定 6 分鐘的時(shí)引矩。
如果你有足夠的耐心,你將會看到雞蛋的顏色隨著時(shí)間漸漸改變侵浸,并在完成時(shí)顯示一個(gè)「DONE旺韭!」。
按鈕和菜單
界面上的按鈕以及菜單里的菜單項(xiàng)應(yīng)該隨著 timer 的狀態(tài)自動啟用或禁用掏觉。
把這個(gè)方法添加到 ViewController
中盛放用于顯示相關(guān)方法的 Extension 擴(kuò)展中:
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)
}
}
這個(gè)方法使用 EggTimer
的狀態(tài)(還記得你添加到 EggTimer
里的計(jì)算屬性嗎)來計(jì)算出哪個(gè)按鈕應(yīng)該啟用区端。
在第二部分中,你創(chuàng)立了一個(gè) Timer menu item 作為 AppDelegate
的屬性澳腹,所以我們應(yīng)該在 AppDelegate
中來編輯這些代碼织盼。
切換到 AppDelegate.swift,在其中添加這個(gè)方法:
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
為了讓你的你的 app 能在初次啟動時(shí)自動配置按鈕的啟用狀態(tài)酱塔,在 applicationDidFinishLaunching
方法中添加這些代碼:
enableMenus(start: true, stop: false, reset: false)
每當(dāng)用戶按下了任何一個(gè)按鈕或菜單項(xiàng)的時(shí)候沥邻,EggTimer
的狀態(tài)會發(fā)生改變,按鈕或菜單項(xiàng)的狀態(tài)也需要隨之更新羊娃。返回到 ViewController.swift 中并把這一行添加到三個(gè)按鈕的 IBAction 方法中:
configureButtonsAndMenus()
再次編譯并運(yùn)行你的 app唐全,你可以看到按鈕們?nèi)珙A(yù)期地啟用和禁用了。點(diǎn)擊菜單里的菜單項(xiàng)試試蕊玷,它們應(yīng)該擁有和按鈕一樣的功能邮利。
偏好設(shè)置窗口
這個(gè) app 還有一個(gè)很重要的問題:如果你希望煮雞蛋的時(shí)間不是 6 分鐘呢弥雹?
在第二部分中,你已經(jīng)設(shè)計(jì)好了一個(gè)偏好設(shè)置窗口來允許用戶來選擇需要的倒計(jì)時(shí)時(shí)間延届,這個(gè)窗口是由 PrefsViewController
控制的剪勿,但它還需要一個(gè) Model 對象來處理和查詢數(shù)據(jù)。
用戶的設(shè)置可以通過一個(gè)叫 UserDefaults
的東西來存儲方庭,它會在你 app 的沙盒容器中的 Preferences 文件夾中用鍵值對來存儲零碎的小數(shù)據(jù)厕吉。
在 Project Navigator(項(xiàng)目導(dǎo)航器) 中,右鍵點(diǎn)擊 Model 分組二鳄,并選擇 Xcode 菜單上的 New File…赴涵,選擇 macOS → Swift File,然后點(diǎn)擊 Next订讼,把文件起名為 Preferences.swift 并點(diǎn)擊 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")
}
}
}
所以這些代碼又干了些啥?
- 定義了一個(gè)名叫
selectedTime
的TimeInterval
計(jì)算屬性欺殿; - 當(dāng)別的代碼請求訪問這個(gè)變量的值的時(shí)候時(shí)寄纵,
UserDefaults
的單例將會去查找鍵「selectedTime」對應(yīng)的Double
值;如果這個(gè)值從沒被定義過脖苏,UserDefaults
將會返回 0程拭;但如果存在這個(gè)值,且它大于 0棍潘,就將這個(gè)值返回恃鞋,并設(shè)置為selectedTime
; - 如果
selectedTime
還沒有被定義過亦歉,就使用默認(rèn)值 360(6 分鐘)恤浪; - 只要
selectedTime
的值發(fā)生了改變,把新的值用鍵「selectedTime」存入UserDefaults
肴楷。
通過使用 getter 和 setter水由,UserDefaults
的數(shù)據(jù)存儲將能夠自動進(jìn)行。
現(xiàn)在切換回 PrefsViewController.swift赛蔫,我們需要把用戶修改的設(shè)置內(nèi)容在界面上顯示出來砂客。
第一步,在 IBOutlet 之下添加這些代碼:
var prefs = Preferences()
這一步中你創(chuàng)建了一個(gè) Preferences
的實(shí)例呵恢,所以你現(xiàn)在可以自由訪問 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)"
}
好像是很大一坨代碼???…所以我們一點(diǎn)一點(diǎn)來看:
- 訪問
prefs
對象的selectedTime
屬性瑰剃,并把它轉(zhuǎn)化成整數(shù)的分鐘數(shù)齿诉; - 把默認(rèn)的計(jì)時(shí)時(shí)間設(shè)置為「Custom」,以防止沒有找到人寰預(yù)設(shè)的數(shù)據(jù);
- 遍歷
presetsPopup
里的菜單項(xiàng)并檢查他們的 tag粤剧,還記得在第二部分中你把每個(gè)項(xiàng)目的 tag 都設(shè)置成了各自選項(xiàng)的分鐘數(shù)了嗎歇竟?如果找到了用戶選擇的菜單項(xiàng),就把這個(gè)菜單項(xiàng)啟用抵恋,并跳出這個(gè)循環(huán)焕议; - 設(shè)置滑動條的數(shù)值,并調(diào)用
showSliderValueAsText
方法弧关; -
showSliderValueAsText
把數(shù)字加上「minute」或「minutes」并將它顯示在界面上的 Text Field 中盅安。
現(xiàn)在,把這行代碼添加到 viewDidLoad
中:
showExistingPrefs()
在 View 加載的時(shí)候世囊,會調(diào)用這個(gè)方法别瞭,把用戶的設(shè)置加載到界面上,在 MVC 模式中株憾,Preferences
Model 完全不知道它佇立的數(shù)據(jù)會怎樣被顯示出來 —— 界面顯示是 PrefsViewController
的事兒蝙寨。
所以,盡管現(xiàn)在你的 app 已經(jīng)可以顯示用戶設(shè)置的時(shí)間了嗤瞎,然而偏好設(shè)置里的下拉框還是不能工作墙歪,你需要為它編寫一個(gè)方法來讓它能存儲新的的設(shè)置,并告訴所有相關(guān)對象數(shù)據(jù)發(fā)生了改變贝奇。
在 EggTimer
對象中虹菲,你使用了 delegate 模式來把數(shù)據(jù)傳遞到需要它的地方,這一次掉瞳,你需要通過發(fā)送一個(gè) Notification
(通知)來告訴大家數(shù)據(jù)改變了(其實(shí)用 delegate 還是可以的毕源,這里只是為了演示 Notification 的用法)。任何對象在表明自己對這個(gè)通知感興趣之后陕习,都可以接收到這個(gè)通知脑豹,并在接收時(shí)采取行動。
在 PrefsViewController
中添加以下方法:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
這個(gè)方法將會獲取 customSlider 滑動條的數(shù)值衡查,并轉(zhuǎn)化成分鐘數(shù),賦值予 selectedTime
必盖,因?yàn)槲覀冎熬帉懙?setter拌牲,它會自動使用 UserDefaults
來存儲新的數(shù)據(jù)。然后 NotificationCenter
(通知中心)會將一個(gè)名叫「PrefsChanged」通知發(fā)送出去歌粥。
接下來塌忽,我們來讓 ViewController
能夠接收到這個(gè) 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()
}
- 當(dāng)用戶在下拉框中選擇了一個(gè)新的菜單項(xiàng)失驶,這段代碼會檢測這個(gè)項(xiàng)是不是 Custom:
- 如果是的土居,就啟用滑動條,并直接終止這個(gè)方法;
- 如果不是擦耀,就通過這個(gè)項(xiàng)的 tag 來獲取用戶選擇的計(jì)時(shí)時(shí)間棉圈;
- 每當(dāng)滑動條的數(shù)據(jù)更新時(shí),更新界面上的文本眷蜓;
- 點(diǎn)擊 Cancel 按鈕會把窗口關(guān)閉分瘾,且不會存儲數(shù)據(jù);
- 點(diǎn)擊 OK 按鈕會先調(diào)用
saveNewPrefs
吁系,然后關(guān)閉這個(gè)窗口德召。
編譯并運(yùn)行你的 app,前往 Preferences汽纤,試著在下拉框中選擇不同的選項(xiàng)上岗,觀察一下滑動條和文本有沒有根據(jù)你的選擇而正確顯示。選擇 Custom 選項(xiàng)蕴坪,然后自己選擇一個(gè)時(shí)間肴掷,點(diǎn)擊 OK,然后再次前往 Preferences辞嗡,看看你剛剛選擇的時(shí)間是不是還能正常顯示捆等。
現(xiàn)在試著退出你的 app 并重新打開它,返回 Preferences续室,看看你的 app 是否保存了你的設(shè)置栋烤。
讓用戶的設(shè)置生效
現(xiàn)在偏好設(shè)置窗口看起來還不錯了 —— 它可以存儲并讀取用戶的設(shè)置,但當(dāng)你回到主窗口挺狰,你看到的時(shí)間會還是 6 分鐘明郭! ??
所以你需要編輯 ViewController.swift,讓它能使用存儲了的數(shù)據(jù)丰泊,并偵聽關(guān)于數(shù)據(jù)變化了的通知薯定,從而及時(shí)更新或重設(shè) Timer。
把這個(gè) Extension 添加到 ViewController.swift 中類定義以外的部分 —— 這樣一來我們的代碼會被分成若干個(gè)承擔(dān)不同職能的部分瞳购,看起來會更整潔话侄。
extension ViewController {
// MARK: - 設(shè)置
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)
}
}
這些代碼會報(bào)錯,因?yàn)?ViewController
內(nèi)部還沒有一個(gè)叫做 prefs
的對象学赛。在 ViewController
類的定義中(也就是你定義 eggTimer
的地方)年堆,添加這行代碼:
var prefs = Preferences()
現(xiàn)在 PrefsViewController
和 ViewController
內(nèi)部都有了一個(gè) prefs 屬性 —— 這是個(gè)問題嗎?不盏浇!原因如下:
-
Preferences
是一個(gè) struct(結(jié)構(gòu)體)变丧,所以它是一個(gè)數(shù)據(jù)型的對象而非一個(gè)關(guān)系型的對象。每一個(gè) View Controller 都可以擁有一份它的副本绢掰; -
Preferences
結(jié)構(gòu)體是使用了UserDefaults
的單例痒蓬,所以這倆副本其實(shí)是在調(diào)用同一個(gè)UserDefaults
童擎,因此拿到的數(shù)據(jù)也是完全一樣的。
在 ViewController 最后的 viewDidLoad
方法中攻晒,添加這一行代碼顾复,它會設(shè)置好自己和 Preferences
的連接:
setupPrefs()
現(xiàn)在還有最后的一系列步驟需要做。之前我們把默認(rèn)的時(shí)間炎辨,也就是 360 秒捕透,直接寫進(jìn)了代碼里(也就是硬編碼,hard-coded)碴萧,現(xiàn)在因?yàn)?ViewController
已經(jīng)可以訪問 Preferences
了乙嘀,你需要修改一下這種寫法。
在 ViewController.swift 中找到「360」(你應(yīng)該能找到 3 個(gè) 360)破喻,并把它們修改成 prefs.selectedTime
虎谢。
編譯并運(yùn)行你的 app,如果你之前修改過設(shè)置里的計(jì)時(shí)時(shí)間曹质,你選擇的時(shí)間現(xiàn)在應(yīng)該能正常顯示在界面上了婴噩。前往 Preferences,選擇另一時(shí)間羽德,點(diǎn)擊 OK —— 因?yàn)?ViewController
接收到了通知几莽,你新選擇的時(shí)間應(yīng)該馬上就能顯示出來了。
啟動計(jì)時(shí)器宅静,然后前往 Preferences章蚣,在主窗口中,倒計(jì)時(shí)還在繼續(xù)姨夹,修改一個(gè)時(shí)間然后點(diǎn)擊 OK纤垂,計(jì)時(shí)器應(yīng)用了新的時(shí)間,但是也停止并重設(shè)了倒計(jì)時(shí)磷账。我覺得這沒什么問題峭沦,但是如果能添加一個(gè)提示,詢問用戶是否真的希望停止計(jì)時(shí)逃糟,這樣會不會更好呢吼鱼?
在 ViewController 中負(fù)責(zé)處理設(shè)置的 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()
}
}
}
所以這些代碼是干啥的绰咽?
- 如果計(jì)時(shí)器已經(jīng)停止或暫停了蛉抓,不做任何操作直接修改時(shí)間;
- 創(chuàng)建一個(gè)
NSAlert
剃诅,它是一個(gè)用來顯示一個(gè)對話框的類,并設(shè)置它的文字和樣子驶忌; - 添加兩個(gè)按鈕:Reset 和 Cancel矛辕,它們將會根據(jù)你添加的順序從右往左顯示在對話框中笑跛,且右邊的將會是默認(rèn)選項(xiàng);
- 把警告以一個(gè)模態(tài)的窗口顯示出來聊品,并等待用戶的選擇飞蹂,如果用戶點(diǎn)擊了第一個(gè)按鈕(Reset),就重設(shè)計(jì)時(shí)器翻屈。
在 setupPrefs
方法中陈哑,把 self.updateFromPrefs()
這一行改成:
self.checkForResetAfterPrefsChange()
編譯并運(yùn)行你的 app,開始計(jì)時(shí)伸眶,前往 Preferences惊窖,修改一下時(shí)間,然后點(diǎn)擊 OK厘贼,你將會看見一個(gè)對話框詢問你是否要重設(shè)時(shí)間界酒。
音效
現(xiàn)在這個(gè) app 中唯一未完成的功能就是音效了。如果沒有「蹲旖眨~~」的一聲的話毁欣,煮蛋計(jì)時(shí)器還能叫做煮蛋計(jì)時(shí)器嗎?
在第二部分中岳掐,你已經(jīng)下載了一個(gè)包含了所有資產(chǎn)的文件夾凭疮,其中的內(nèi)容絕大多數(shù)都是圖片,你也已經(jīng)用過它們了串述,但是其實(shí)這里面還有一個(gè)音效文件:ding.mp3执解。如果你找不到這個(gè)文件了,你可以單獨(dú)下載這個(gè)音效文件剖煌。
把 ding.mp3 拖動到 Project Navigator(項(xiàng)目導(dǎo)航器)中的 EggTimer 分組下方 —— 看起來就放在 Main.storyboard 下邊是一個(gè)不錯的想法材鹦。勾選 Copy items if needed(如果需要的話把文件拷貝到項(xiàng)目中),在 Add to targets(添加到目標(biāo)中) 中勾選 EggTimer耕姊,然后點(diǎn)擊 Finish桶唐。
你需要一個(gè)叫 AVFoundation
的庫來播放聲音。當(dāng)代理告訴 ViewController
計(jì)時(shí)器結(jié)束了的時(shí)候茉兰,ViewController
就會負(fù)責(zé)播放這個(gè)音效尤泽,所以我們切換到 ViewController.swift 中,在最頂部你會看到這個(gè)文件引用了 Cocoa
庫(import Cocoa
)规脸。
在那一行引用的下方坯约,添加:
import AVFoundation
ViewController
需用一個(gè) AVAudioPlayer
來播放聲音,所以我們?yōu)樗砑右粋€(gè)屬性:
var soundPlayer: AVAudioPlayer?
我們應(yīng)該為 ViewController
新建一個(gè)單獨(dú)的 Extension 來處理和聲音相關(guān)的方法莫鸭,所以在 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
方法會負(fù)責(zé)處理絕大多數(shù)的事情 —— 它會先檢查 ding.mp3 是否存在于 app 的包中闹丐,如果這個(gè)文件存在,它就會試圖去用這個(gè)文件的 URL 來實(shí)例化一個(gè) AVAudioPlayer
被因,并準(zhǔn)備好它以備播放卿拴。這將會預(yù)先加載這個(gè)音頻文件衫仑,所以一旦需要,就可以立即播放堕花。
如果 soundPlayer
存在文狱,playSound
會調(diào)用它的 play()
方法;但如果 prepareSound
運(yùn)行失敗了缘挽,soundPlayer
將會為空(nil)瞄崇,因此它什么也不會做。
聲音文件只在 Start 按鈕被點(diǎn)擊時(shí)需要被準(zhǔn)備壕曼,所以把這行代碼插入到 startButtonClicked
方法的最后:
prepareSound()
在 EggTimerProtocol Extension 的 timerHasFinished 方法中苏研,追加這行代碼:
playSound()
編譯并運(yùn)行之,選擇一個(gè)短一點(diǎn)的時(shí)間并開始計(jì)時(shí)窝稿,一聲清脆的「叮??」會在計(jì)時(shí)結(jié)束的時(shí)候響起楣富。
現(xiàn)在該做些什么?
你可以下載這個(gè)項(xiàng)目的源代碼伴榔。
在這個(gè) macOS 開發(fā)教程中纹蝴,你已經(jīng)掌握了開發(fā) macOS app 的基本技能,但真正要學(xué)習(xí)的還有很多踪少!
Apple 編寫了許多很棒的文檔塘安,他們覆蓋了 macOS 開發(fā)的方方面面。
我同時(shí)強(qiáng)烈建議你去看看我們(原作者)的網(wǎng)站 raywenderlich.com 上的其他 macOS 教程援奢。
如果你還有任何問題兼犯,歡迎在原文下方參與討論!