macOS Development for Beginners: Part 3

In this macOS Development tutorial for beginners, learn how to add the code to the UI you developed in part 2 to make a fully operational egg timer. By Roberto Machorro.

4.3 (22) · 1 Review

Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

ViewController

Now that the EggTimer object is working, its time to go back to ViewController.swift and make the display change to reflect this.

ViewController already has the @IBOutlet properties, but now give it a property for the EggTimer:

  var eggTimer = EggTimer()

Add this line to viewDidLoad, replacing the comment line:

    eggTimer.delegate = self

This is going to cause an error because ViewController does not conform to the EggTimerProtocol. When conforming to a protocol, it makes your code neater if you create a separate extension for the protocol functions. Add this code below the ViewController class definition:

extension ViewController: EggTimerProtocol {

  func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
    updateDisplay(for: timeRemaining)
  }

  func timerHasFinished(_ timer: EggTimer) {
    updateDisplay(for: 0)
  }
}

The error disappears because ViewController now has the two functions required by EggTimerProtocol. However both these functions are calling updateDisplay which doesn't exist yet.

Here is another extension for ViewController which contains the display functions:

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 uses private functions to get the text and the image for the supplied remaining time, and display these in the text field and image view.

textToDisplay converts the seconds remaining to M:SS format. imageToDisplay calculates how much the egg is done as a percentage of the total and picks the image to match.

So the ViewController has an EggTimer object and it has the functions to receive data from EggTimer and display the result, but the buttons have no code yet. In Part 2, you set up the @IBActions for the buttons.

Here is the code for these action functions, so you can replace them with this:

  @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)
  }

These 3 actions call the EggTimer methods you added earlier.

Build and run the app now and then click the Start button.

There are a couple of features missing still: the Stop & Reset buttons are always disabled and you can only have a 6 minute egg. You can use the Timer menu to control the app; try stopping, starting and resetting using the menu and the keyboard shortcuts.

If you are patient enough to wait for it, you will see the egg change color as it cooks and finally show "DONE!" when it is ready.

Cooking

Buttons and Menus

The buttons should become enabled or disabled depending on the timer state and the Timer menu items should match that.

Add this function to the ViewController, inside the extension with the Display functions:

  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)
    }
  }

This function uses the EggTimer status (remember the computed variables you added to EggTimer) to work out which buttons should be enabled.

In Part 2, you set up the Timer menu items as properties of the AppDelegate, so the AppDelegate is where they can be configured.

Switch to AppDelegate.swift and add this function:

  func enableMenus(start: Bool, stop: Bool, reset: Bool) {
    startTimerMenuItem.isEnabled = start
    stopTimerMenuItem.isEnabled = stop
    resetTimerMenuItem.isEnabled = reset
  }

So that your menus are correctly configured when the app first launches, add this line to the applicationDidFinishLaunching method:

enableMenus(start: true, stop: false, reset: false)

The buttons and menus needs to be changed whenever a button or menu item action changes the state of the EggTimer. Switch back to ViewController.swift and add this line to the end of each of the 3 button action functions and the timerHasFinished function:

    configureButtonsAndMenus()

Build and run the app again and you can see that the buttons enable and disable as expected. Check the menu items; they should mirror the state of the buttons.

Preferences

There is really only one big problem left for this app - what if you don't like your eggs boiled for 6 minutes?

In Part 2, you designed a Preferences window to allow selection of a different time. This window is controlled by the PrefsViewController, but it needs a model object to handle the data storage and retrieval.

Preferences are going be stored using UserDefaults which is a key-value way of storing small pieces of data in the Preferences folder in your app's Container.

Right-click on the Model group in the Project Navigator and choose New File... Select macOS/Swift File and click Next. Name the file Preferences.swift and click Create. Add this code to the Preferences.swift file:

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")
    }
  }

}

So what does this code do?

  1. A computed variable called selectedTime is defined as a TimeInterval.
  2. When the value of the variable is requested, the UserDefaults singleton is asked for the Double value assigned to the key "selectedTime". If the value has not been defined, UserDefaults will return zero, but if the value is greater than 0, return that as the value of selectedTime.
  3. If selectedTime has not been defined, use the default value of 360 (6 minutes).
  4. Whenever the value of selectedTime is changed, write the new value to UserDefaults with the key "selectedTime".

So by using a computed variable with a getter and a setter, the UserDefaults data storage will be handled automatically.

Now switch the PrefsViewController.swift, where the first task is to update the display to reflect any existing preferences or the defaults.

First, add this property just below the outlets:

var prefs = Preferences()

Here you create an instance of Preferences so the selectedTime computed variable is accessible.

Then, add these methods:

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)"
}

This looks like a lot of code, so just go through it step by step:

  1. Ask the prefs object for its selectedTime and convert it from seconds to whole minutes.
  2. Set the defaults to "Custom" in case no matching preset value is found.
  3. Loop through the menu items in the presetsPopup checking their tags. Remember in Part 2 how you set the tags to the number of minutes for each option? If a match is found, enable that item and get out of the loop.
  4. Set the value for the slider and call showSliderValueAsText.
  5. showSliderValueAsText adds "minute" or "minutes" to the number and shows it in the text field.

Now, add this to viewDidLoad:

showExistingPrefs()

When the view loads, call the method that shows the preferences in the display. Remember, using the MVC pattern, the Preferences model object has no idea about how or when it might be displayed - that is for the PrefsViewController to manage.

So now you have the ability to display the set time, but changing the time in the popup doesn't do anything yet. You need a method that saves the new data and tells anyone who is interested that the data has changed.

In the EggTimer object, you used the delegate pattern to pass data to whatever needed it. This time (just to be different), you are going to broadcast a Notification when the data changes. Any object that choses can listen for this notification and act on it when received.

Insert this method into PrefsViewController:

  func saveNewPrefs() {
    prefs.selectedTime = customSlider.doubleValue * 60
    NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
                                    object: nil)
  }

This gets the data from the custom slider (you will see in a minute that any changes are reflected there). Setting the selectedTime property will automatically save the new data to UserDefaults. Then a notification with the name "PrefsChanged" is posted to the NotificationCenter.

In a minute, you will see how the ViewController can be set to listen for this Notification and react to it.

The final step in coding the PrefsViewController is to set the code for the @IBActions you added in Part 2:

  // 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. When a new item is chosen from the popup, check to see if it is the Custom menu item. If so, enable the slider and get out. If not, use the tag to get the number of minutes, use them to set the slider value and text and disable the slider.
  2. Whenever the slider changes, update the text.
  3. Clicking Cancel just closes the window but does not save the changes.
  4. Clicking OK calls saveNewPrefs first and then closes the window.

Build and run the app now and go to Preferences. Try choosing different options in the popup - notice how the slider and text change to match. Choose Custom and pick your own time. Click OK, then come back to Preferences and confirm that your chosen time is still displayed.

Now try quitting the app and restarting. Go back to Preferences and see that it has saved your setting.

Contributors

Zoltán Matók

Tech Editor

Chris Belanger

Editor

Michael Briscoe

Final Pass Editor and Team Lead

Over 300 content creators. Join our team.