macOS 新手開發(fā):第 3 部分

歡迎回到 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.plistEggTimer.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 將自身分配為 EggTimerdelegate,所以由它來接收消息,然后它可以在自己的 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ā)生什么?

  1. startTime 是一個(gè) Optional Date——如果是 nil,timer 就無法運(yùn)行,所以什么都不會(huì)發(fā)生。
  2. 重新計(jì)算 elapsedTime 屬性。 startTime 早于當(dāng)前,因此 timeIntervalSinceNow 會(huì)生成負(fù)數(shù)。用減號(hào)使得 elapsedTime 是正數(shù)。
  3. 計(jì)算 timer 的剩余秒數(shù),四舍五入以給出整數(shù)秒。
  4. 如果 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ù)做了什么?

  1. startTimer 使用 Date() 將啟動(dòng)時(shí)間設(shè)置為現(xiàn)在、設(shè)置了重復(fù)的 Timer。
  2. resumeTimer 是 timer 已暫停并正在重新啟動(dòng)時(shí)調(diào)用的內(nèi)容。基于已過去的時(shí)間重新計(jì)算開始時(shí)間。
  3. stopTimer 停止了重復(fù)的 timer。
  4. 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")
    }
  }
 
}

這段代碼可以做什么?

  1. 叫做 selectedTime 的計(jì)算變量定義為 TimeInterval。
  2. 請(qǐng)求變量的值時(shí),UserDefaults 單例取出分配給鍵 “selectedTime” 的Double 值。如果值未定義,UserDefaults 將返回零,但如果值大于 0,則將其作為 selectedTime 的值返回。
  3. 如果 selectedTime 沒有被定義,使用默認(rèn)值 360(6 分鐘)。
  4. 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)"
}

看上去有很多代碼,一步步看一遍:

  1. 請(qǐng)求 prefs 對(duì)象的 selectedTime,并將其從秒數(shù)轉(zhuǎn)換為整數(shù)分鐘。
  2. 如果找不到匹配的預(yù)設(shè)值,請(qǐng)將默認(rèn)值設(shè)置為 “Custom”。
  3. 遍歷循環(huán) presetsPopup 中的菜單項(xiàng)檢查他們的 tag。還記得在第 2 部分中如何將 tag 設(shè)置為每個(gè)選項(xiàng)的分鐘數(shù)嗎?如果找到匹配,啟用該項(xiàng)目并退出循環(huán)。
  4. 設(shè)置滑塊的值并調(diào)用 showSliderValueAsText。
  5. 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()
  }
  1. 從彈出窗口中選擇一個(gè)新項(xiàng)目時(shí),檢查它是否是自定義菜單項(xiàng)。如果是,啟用滑塊并退出。如果沒有,使用 tag 獲取分鐘數(shù),使用它們來設(shè)置滑塊值和文本,并禁用滑塊。
  2. 滑塊變動(dòng)時(shí)更新文字。
  3. 點(diǎn)擊 Cancel 會(huì)關(guān)閉窗口,且不保存改變。
  4. 點(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è)原因。

  1. Preferences 是一個(gè)結(jié)構(gòu)體,因此它是基于值的,不是基于引用的。每個(gè) View Controller 都有自己的副本。
  2. 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ā)生了什么?

  1. 如果 timer 停止或暫停了,不用詢問直接弄。
  2. 創(chuàng)建一個(gè) NSAlert,它是顯示對(duì)話框的類。配置其文本和樣式。
  3. 添加2個(gè)按鈕:Reset 和 Cancel。它們將按照從右到左的順序顯示,第一個(gè)將是默認(rèn)選項(xiàng)。
  4. 將 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 NavigatorEggTimer 組內(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)論!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 原文 歡迎回到我們的新手 macOS 開發(fā)系列教程,這是我第三部分,也是最后一部分。 在第一部分你學(xué)習(xí)了怎樣安裝 ...
    z_k閱讀 809評(píng)論 0 1
  • 本文翻譯自 raywenderlich.com 的 macOS 開發(fā)經(jīng)典入門教程 ,已咨詢對(duì)方網(wǎng)站,可至多翻譯 1...
    SR2k閱讀 2,267評(píng)論 0 4
  • 你想學(xué)習(xí)如何開發(fā)自己的 macOS app 嗎? 好消息!蘋果讓 macOS 開發(fā)變得相當(dāng)簡(jiǎn)單,在本教程中你會(huì)學(xué)到...
    張嘉夫閱讀 20,775評(píng)論 10 51
  • 現(xiàn)在這個(gè)高科技和信息發(fā)達(dá)的時(shí)代,你若不改變思路,投入狀態(tài),時(shí)代拋棄你,不會(huì)打招呼。因?yàn)橛辛算y行卡和手機(jī),...
    亞涓閱讀 1,656評(píng)論 27 58
  • 近日,網(wǎng)上新聞,廈大一附屬醫(yī)院男研究員陸某,已婚出軌廈大女博士,并且該女博士還同時(shí)與四個(gè)博士交往,知道真相...
    大魚與驢閱讀 1,194評(píng)論 7 8

友情鏈接更多精彩內(nèi)容