[譯] 零基礎(chǔ) macOS 應(yīng)用開發(fā)(三)

本文翻譯自 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.plistEggTimer.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 菜單欄上的 FileNewFile…掌实,選擇 macOSSwift 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) 
    }
}

…所以這些代碼到底是在做些什么成翩?

  1. startTime 是個(gè)可選的 Date,當(dāng)它是 nil 時(shí)赦役,timer 將無法運(yùn)行麻敌,所以這時(shí)什么都不會發(fā)生;
  2. 重新計(jì)算 elapsedTime 屬性掂摔,startTime 比當(dāng)前的時(shí)間還要早术羔,所以 timeIntervalSinceNow 會產(chǎn)生一個(gè)負(fù)值职辅,這個(gè)負(fù)值會使得 elapsedTime 成為一個(gè)正值;
  3. 計(jì)算 timer 的剩余時(shí)間聂示,并進(jìn)行取整域携;
  4. 如果 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() 
}

這些代碼是做什么的?

  1. 通過調(diào)用 Date() 方法 startTimer 設(shè)置開始時(shí)間為當(dāng)前時(shí)間蝌戒,然后它會設(shè)置一個(gè)一直重復(fù)運(yùn)行的 Timer串塑;
  2. resumeTimer 是計(jì)時(shí)器已經(jīng)暫停并需要繼續(xù)時(shí)會被調(diào)用的方法,它還會根據(jù)已經(jīng)過去的時(shí)間重新設(shè)置開始時(shí)間北苟;
  3. stopTimer 會停止重復(fù)運(yùn)行的 timer桩匪;
  4. 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…赴涵,選擇 macOSSwift 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")
        }
    }

}

所以這些代碼又干了些啥?

  1. 定義了一個(gè)名叫 selectedTimeTimeInterval 計(jì)算屬性欺殿;
  2. 當(dāng)別的代碼請求訪問這個(gè)變量的值的時(shí)候時(shí)寄纵,UserDefaults 的單例將會去查找鍵「selectedTime」對應(yīng)的 Double 值;如果這個(gè)值從沒被定義過脖苏,UserDefaults 將會返回 0程拭;但如果存在這個(gè)值,且它大于 0棍潘,就將這個(gè)值返回恃鞋,并設(shè)置為 selectedTime
  3. 如果 selectedTime 還沒有被定義過亦歉,就使用默認(rèn)值 360(6 分鐘)恤浪;
  4. 只要 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)來看:

  1. 訪問 prefs 對象的 selectedTime 屬性瑰剃,并把它轉(zhuǎn)化成整數(shù)的分鐘數(shù)齿诉;
  2. 把默認(rèn)的計(jì)時(shí)時(shí)間設(shè)置為「Custom」,以防止沒有找到人寰預(yù)設(shè)的數(shù)據(jù);
  3. 遍歷 presetsPopup 里的菜單項(xiàng)并檢查他們的 tag粤剧,還記得在第二部分中你把每個(gè)項(xiàng)目的 tag 都設(shè)置成了各自選項(xiàng)的分鐘數(shù)了嗎歇竟?如果找到了用戶選擇的菜單項(xiàng),就把這個(gè)菜單項(xiàng)啟用抵恋,并跳出這個(gè)循環(huán)焕议;
  4. 設(shè)置滑動條的數(shù)值,并調(diào)用 showSliderValueAsText 方法弧关;
  5. 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()
}
  1. 當(dāng)用戶在下拉框中選擇了一個(gè)新的菜單項(xiàng)失驶,這段代碼會檢測這個(gè)項(xiàng)是不是 Custom:
    • 如果是的土居,就啟用滑動條,并直接終止這個(gè)方法;
    • 如果不是擦耀,就通過這個(gè)項(xiàng)的 tag 來獲取用戶選擇的計(jì)時(shí)時(shí)間棉圈;
  2. 每當(dāng)滑動條的數(shù)據(jù)更新時(shí),更新界面上的文本眷蜓;
  3. 點(diǎn)擊 Cancel 按鈕會把窗口關(guān)閉分瘾,且不會存儲數(shù)據(jù);
  4. 點(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)在 PrefsViewControllerViewController 內(nèi)部都有了一個(gè) prefs 屬性 —— 這是個(gè)問題嗎?不盏浇!原因如下:

  1. Preferences 是一個(gè) struct(結(jié)構(gòu)體)变丧,所以它是一個(gè)數(shù)據(jù)型的對象而非一個(gè)關(guān)系型的對象。每一個(gè) View Controller 都可以擁有一份它的副本绢掰;
  2. 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()
        }
    }
}

所以這些代碼是干啥的绰咽?

  1. 如果計(jì)時(shí)器已經(jīng)停止或暫停了蛉抓,不做任何操作直接修改時(shí)間;
  2. 創(chuàng)建一個(gè) NSAlert剃诅,它是一個(gè)用來顯示一個(gè)對話框的類,并設(shè)置它的文字和樣子驶忌;
  3. 添加兩個(gè)按鈕:Reset 和 Cancel矛辕,它們將會根據(jù)你添加的順序從右往左顯示在對話框中笑跛,且右邊的將會是默認(rèn)選項(xiàng);
  4. 把警告以一個(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 教程援奢。

如果你還有任何問題兼犯,歡迎在原文下方參與討論!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末集漾,一起剝皮案震驚了整個(gè)濱河市切黔,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌具篇,老刑警劉巖纬霞,帶你破解...
    沈念sama閱讀 217,406評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異驱显,居然都是意外死亡诗芜,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,732評論 3 393
  • 文/潘曉璐 我一進(jìn)店門埃疫,熙熙樓的掌柜王于貴愁眉苦臉地迎上來伏恐,“玉大人,你說我怎么就攤上這事栓霜〈滂耄” “怎么了?”我有些...
    開封第一講書人閱讀 163,711評論 0 353
  • 文/不壞的土叔 我叫張陵胳蛮,是天一觀的道長秤掌。 經(jīng)常有香客問我愁铺,道長,這世上最難降的妖魔是什么闻鉴? 我笑而不...
    開封第一講書人閱讀 58,380評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮茂洒,結(jié)果婚禮上孟岛,老公的妹妹穿的比我還像新娘。我一直安慰自己督勺,他們只是感情好渠羞,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,432評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著智哀,像睡著了一般次询。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上瓷叫,一...
    開封第一講書人閱讀 51,301評論 1 301
  • 那天屯吊,我揣著相機(jī)與錄音,去河邊找鬼摹菠。 笑死盒卸,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的次氨。 我是一名探鬼主播蔽介,決...
    沈念sama閱讀 40,145評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼煮寡!你這毒婦竟也來了虹蓄?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,008評論 0 276
  • 序言:老撾萬榮一對情侶失蹤幸撕,失蹤者是張志新(化名)和其女友劉穎薇组,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體杈帐,經(jīng)...
    沈念sama閱讀 45,443評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡体箕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,649評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了挑童。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片累铅。...
    茶點(diǎn)故事閱讀 39,795評論 1 347
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖站叼,靈堂內(nèi)的尸體忽然破棺而出娃兽,到底是詐尸還是另有隱情,我是刑警寧澤尽楔,帶...
    沈念sama閱讀 35,501評論 5 345
  • 正文 年R本政府宣布投储,位于F島的核電站第练,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏玛荞。R本人自食惡果不足惜娇掏,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,119評論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望勋眯。 院中可真熱鬧婴梧,春花似錦、人聲如沸客蹋。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,731評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽讶坯。三九已至番电,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間辆琅,已是汗流浹背漱办。 一陣腳步聲響...
    開封第一講書人閱讀 32,865評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留涎跨,地道東北人洼冻。 一個(gè)月前我還...
    沈念sama閱讀 47,899評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像隅很,于是被迫代替她去往敵國和親撞牢。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,724評論 2 354

推薦閱讀更多精彩內(nèi)容

  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫叔营、插件屋彪、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,102評論 4 62
  • 在中國畜挥,很多夫妻離婚都會東躲西藏,隱瞞天下婴谱,而且這頭號要瞞的就是孩子蟹但。 究其原因無非是:要給孩子一個(gè)美好的童年,要...
    父母一刻閱讀 622評論 0 1
  • 國慶節(jié)快樂疤犯帷华糖!手機(jī)里幾乎都是關(guān)于國慶節(jié)的祝福,有的是好朋友發(fā)的瘟裸,有的是平時(shí)不怎么聯(lián)系的人發(fā)來的客叉,雖然語言都...
    半支玫瑰花閱讀 796評論 0 1
  • 今日,看女兒作詩一首,甚是喜歡兼搏,轉(zhuǎn)載于此卵慰,廖做分享。 白雪歌 作者:吟寒子...
    秋靈塵犀閱讀 330評論 0 1
  • 涉水百里尋幽靜佛呻, 梨花農(nóng)野自常清裳朋。 渡河南下百家樂, 不是親來勝似親吓著。
    康康戲劇閱讀 215評論 0 0