[譯] 零基礎 macOS 應用開發(三)

本文翻譯自 raywenderlich.com 的 macOS 開發經典入門教程,已諮詢對方網站,可至多翻譯 10 篇文章。
翻譯它只是由於宿舍太吵太熱,只有這樣才能一句一句看完,並做爲本身的筆記,但願各位有英語閱讀能力的話,仍是去閱讀英文原吧,畢竟不管是 Xcode,抑或是官方的文檔,仍是各類最前沿的資訊都只有英文版本。
綜上,此翻譯版本僅供參考,謝絕轉載macos

相關連接
零基礎 macOS 應用開發(一): 原文 / 譯文
零基礎 macOS 應用開發(二): 原文 / 譯文
零基礎 macOS 應用開發(三): 原文 / 譯文(本文)swift

歡迎回到咱們的零基礎 macOS 應用開發教程的最後一部分(共三部分)!設計模式

在第一部分中,你已經學會了如何安裝 Xcode 和如何建立一個示例 app;在第二部分中你爲一個更加複雜的 app 建立了 UI,但由於你尚未編寫任何代碼,因此它還不能工做。在這個部分中,你將會編寫全部 Swift 代碼並讓你的 app 真正活起來!app

開始

若是你尚未完成第二部分,或你但願從一個更加純淨的狀況繼續學習,你能夠下載第二部分中已經完成了 UI 佈局的工程文件。打開你下載的或你跟着第二部分完成的工程文件,並運行一下它,確認一下是否全部的 UI 都能正確顯示,打開偏好設置窗口看看它是否能正常顯示。ide

沙盒機制

在你開始編寫代碼以前,請花一些時間來了解一下 macOS 的沙盒機制。若是你是一個 iOS 開發者,你已經瞭解了這個概念,若是你未曾瞭解過,繼續往下閱讀。佈局

一個沙盒化了的 app 擁有本身獨立的存儲空間,沙盒會禁止你的 app 訪問另外一個 app 建立的文件以及其餘的許可和限制。對於 iOS app,使用沙盒是必須的,而對於 macOS app,這只是一個可選項;但若是你但願經過 Mac App Store 進行分發和銷售,你的 app 必須沙盒化,因爲沙盒帶來的諸多限制,你的 app 可能會出現一些問題。post

要爲你的 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.plistEggTimer.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 菜單欄上的 FileNewFile…,選擇 macOSSwift 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) 
    }
}

…因此這些代碼究竟是在作些什麼?

  1. startTime 是個可選的 Date,當它是 nil 時,timer 將沒法運行,因此這時什麼都不會發生;
  2. 從新計算 elapsedTime 屬性,startTime 比當前的時間還要早,因此 timeIntervalSinceNow 會產生一個負值,這個負值會使得 elapsedTime 成爲一個正值;
  3. 計算 timer 的剩餘時間,並進行取整;
  4. 若是 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() 
}

這些代碼是作什麼的?

  1. 經過調用 Date() 方法 startTimer 設置開始時間爲當前時間,而後它會設置一個一直重複運行的 Timer
  2. resumeTimer 是計時器已經暫停並須要繼續時會被調用的方法,它還會根據已通過去的時間從新設置開始時間;
  3. stopTimer 會中止重複運行的 timer;
  4. 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…,選擇 macOSSwift 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")
        }
    }

}

因此這些代碼又幹了些啥?

  1. 定義了一個名叫 selectedTimeTimeInterval 計算屬性;
  2. 當別的代碼請求訪問這個變量的值的時候時,UserDefaults 的單例將會去查找鍵「selectedTime」對應的 Double 值;若是這個值從沒被定義過,UserDefaults 將會返回 0;但若是存在這個值,且它大於 0,就將這個值返回,並設置爲 selectedTime
  3. 若是 selectedTime 尚未被定義過,就使用默認值 360(6 分鐘);
  4. 只要 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)"
}

好像是很大一坨代碼?️…因此咱們一點一點來看:

  1. 訪問 prefs 對象的 selectedTime 屬性,並把它轉化成整數的分鐘數;
  2. 把默認的計時時間設置爲「Custom」,以防止沒有找到人寰預設的數據;
  3. 遍歷 presetsPopup 裏的菜單項並檢查他們的 tag,還記得在第二部分中你把每一個項目的 tag 都設置成了各自選項的分鐘數了嗎?若是找到了用戶選擇的菜單項,就把這個菜單項啓用,並跳出這個循環;
  4. 設置滑動條的數值,並調用 showSliderValueAsText 方法;
  5. 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()
}
  1. 當用戶在下拉框中選擇了一個新的菜單項,這段代碼會檢測這個項是否是 Custom:

    • 若是是的,就啓用滑動條,並直接終止這個方法;
    • 若是不是,就經過這個項的 tag 來獲取用戶選擇的計時時間;
  2. 每當滑動條的數據更新時,更新界面上的文本;
  3. 點擊 Cancel 按鈕會把窗口關閉,且不會存儲數據;
  4. 點擊 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()

如今 PrefsViewControllerViewController 內部都有了一個 prefs 屬性 —— 這是個問題嗎?不!緣由以下:

  1. Preferences 是一個 struct(結構體),因此它是一個數據型的對象而非一個關係型的對象。每個 View Controller 均可以擁有一份它的副本;
  2. 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()
        }
    }
}

因此這些代碼是幹啥的?

  1. 若是計時器已經中止或暫停了,不作任何操做直接修改時間;
  2. 建立一個 NSAlert,它是一個用來顯示一個對話框的類,並設置它的文字和樣子;
  3. 添加兩個按鈕:Reset 和 Cancel,它們將會根據你添加的順序從右往左顯示在對話框中,且右邊的將會是默認選項;
  4. 把警告以一個模態的窗口顯示出來,並等待用戶的選擇,若是用戶點擊了第一個按鈕(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 教程。

若是你還有任何問題,歡迎在原文下方參與討論!

相關文章
相關標籤/搜索