1

macOS Development for Beginners: Part 3

 3 years ago
source link: https://www.raywenderlich.com/729-macos-development-for-beginners-part-3
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

In Part 1, you learned how to install Xcode and how to create a simple app. In Part 2, you created the user interface for a more complex app, but it doesn’t work yet as you have not coded anything. In this part, you are going to add the Swift code that will make your app come to life!

Getting Started

If you haven’t completed Part 2 or want to start with a clean slate, you can download the project files with the app UI laid out as it was at the end of Part 2. Open this project or your own project from Part 2 and run it to confirm that the UI is all in place. Open the Preferences window to check it as well.

RunPrefs

Sandboxing

Before you dive into the code, take a minute to consider sandboxing. If you are an iOS developer, you will already be familiar with this concept – if not, read on.

A sandboxed app has its own space to work in with separate file storage areas, no access to the files created by other apps and limited access and permissions. For iOS apps, this is the only way to operate. For macOS apps, this is optional; however, if you want to distribute your apps through the Mac App Store, they must be sandboxed. As a general rule, you should sandbox your apps, as this gives your apps less potential to cause problems.

To turn on sandboxing for the Egg Timer app, select the project in the Project Navigator — this is the top entry with the blue icon. Select EggTimer in the Targets list (there will only be one target listed), then click Capabilities in the tabs across the top. Click the switch to turn on App Sandbox. The display will expand to show the various permissions you can now request for your app. This app doesn’t need any of these, so leave them all unchecked.

Sandbox

Organizing Your Files

Look at the Project Navigator. All the files are listed with no particular organization. This app will not have very many files, but grouping similar files together is good practice and allows for more efficient navigation, especially with larger projects.

ProjectNavigatorStart

Select the two view controller files by clicking on one and Shift-clicking on the next. Right-click and choose New Group from Selection from the popup menu. Name the new group View Controllers.

The project is about to get some model files, so select the top EggTimer group, right-click and choose New Group. Call this one Model.

Finally, select Info.plist and EggTimer.entitlements and put them into a group called Supporting Files.

Drag the groups and files around until your Project Navigator looks like this:

ProjectNavigatorEnd

This app is using the MVC pattern: Model View Controller.

The main model object type for the app is going to be a class called EggTimer. This class will have properties for the start time of the timer, the requested duration and the elapsed time. It will also have a Timer object that fires every second to update itself. Methods will start, stop, resume or reset the EggTimer object.

The EggTimer model class holds data and performs actions, but has no knowledge of how this is displayed. The Controller (in this case ViewController), knows about the EggTimer class (the Model) and has a View that it can use to display the data.

To communicate back to the ViewController, EggTimer uses a delegate protocol. When something changes, the EggTimer sends a message to its delegate. The ViewController assigns itself as the EggTimer's delegate, so it is the one that receives the message and then it can display the new data in its own View.

Coding the EggTimer

Select the Model group in the Project Navigator and choose File/New/File… Select macOS/Swift File and click Next. Give the file a name of EggTimer.swift and click Create to save it.

Add the following code:

class EggTimer {

  var timer: Timer? = nil
  var startTime: Date?
  var duration: TimeInterval = 360      // default = 6 minutes
  var elapsedTime: TimeInterval = 0

}

This sets up the EggTimer class and its properties. TimeInterval really means Double, but is used when you want to show that you mean seconds.

The next thing is to add two computed properties inside the class, just after the previous properties:

  var isStopped: Bool {
    return timer == nil && elapsedTime == 0
  }
  var isPaused: Bool {
    return timer == nil && elapsedTime > 0
  }

These are convenient shortcuts that can be used to determine the state of the EggTimer.

Insert the definition for the delegate protocol into the EggTimer.swift file but outside the EggTimer class – I like to put protocol definitions at the top of the file, after the import.

protocol EggTimerProtocol {
  func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
  func timerHasFinished(_ timer: EggTimer)
}

A protocol sets out a contract and any object that is defined as conforming to the EggTimerProtocol must supply these 2 functions.

Now that you have defined a protocol, the EggTimer can get an optional delegate property which is set to any object that conforms to this protocol. EggTimer does not know or care what type of object the delegate is, because it is certain that the delegate has those two functions.

Add this line to the existing properties in the EggTimer class:

  var delegate: EggTimerProtocol?

Starting the EggTimer‘s timer object will fire off a function call every second. Insert this code which defines the function that will be called by the timer. The dynamic keyword is essential for the Timer to be able to find it.

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

So what's happening here?

  1. startTime is an Optional Date - if it is nil, the timer cannot be running so nothing happens.
  2. Re-calculate the elapsedTime property. startTime is earlier than now, so timeIntervalSinceNow produces a negative value. The minus sign changes it so that elapsedTime is a positive number.
  3. Calculate the seconds remaining for the timer, rounded to give a whole number of seconds.
  4. If the timer has finished, reset it and tell the delegate it has finished. Otherwise, tell the delegate the number of seconds remaining. As delegate is an optional property, the ? is used to perform optional chaining. If the delegate is not set, these methods will not be called but nothing bad will happen.

You will see an error until you add the final bit of code needed for the EggTimer class: the methods for starting, stopping, resuming and resetting the timer.

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

What are these functions doing?

  1. startTimer sets the start time to now using Date() and sets up the repeating Timer.
  2. resumeTimer is what gets called when the timer has been paused and is being re-started. The start time is re-calculated based on the elapsed time.
  3. stopTimer stops the repeating timer.
  4. resetTimer stops the repeating timer and sets the properties back to the defaults.

All these functions also call timerAction so that the display can update immediately.

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:

    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.

ButtonsMenus

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.

PrefsChanges

Implementing Selected Preferences

The Preferences window is looking good - saving and restoring your selected time as expected. But when you go back to the main window, you are still getting a 6 minute egg! :[

So you need to edit ViewController.swift to use the stored value for the timing and to listen for the Notification of change so the timer can be changed or reset.

Add this extension to ViewController.swift outside any existing class definition or extension - it groups all the preferences functionality into a separate package for neater code:

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

}

This will give errors, because ViewController has no object called prefs. In the main ViewController class definition, where you defined the eggTimer property, add this line:

  var prefs = Preferences()

Now PrefsViewController has a prefs object and so does ViewController - is this a problem? No, for a couple of reasons.

  1. Preferences is a struct, so it is value-based not reference-based. Each View Controller gets its own copy.
  2. The Preferences struct interacts with UserDefaults through a singleton, so both copies are using the same UserDefaults and getting the same data.

At the end of the ViewController viewDidLoad function, add this call which will set up the Preferences connection:

    setupPrefs()

There is one final set of edits needed. Earlier, you were using hard-coded values for timings - 360 seconds or 6 minutes. Now that ViewController has access to Preferences, you want to change these hard-coded 360's to prefs.selectedTime.

Search for 360 in ViewController.swift and change each one to prefs.selectedTime - you should be able to find 3 of them.

Build and run the app. If you have changed your preferred egg time earlier, the time remaining will display whatever you chose. Go to Preferences, chose a different time and click OK - your new time will immediately be shown as ViewController receives the Notification.

PrefsUpdating

Start the timer, then go to Preferences. The countdown continues in the back window. Change your egg timing and click OK. The timer applies your new time, but stops the timer and resets the counter. This is OK, I suppose, but it would be better if the app warned you this was going to happen. How about adding a dialog that asks if that is really what you want to do?

In the ViewController extension that deals with Preferences, add this function:

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

So what's going on here?

  1. If the timer is stopped or paused, just do the reset without asking.
  2. Create an NSAlert which is the class that displays a dialog box. Configure its text and style.
  3. Add 2 buttons: Reset & Cancel. They will appear from right to left in the order you add them and the first one will be the default.
  4. Show the alert as a modal dialog and wait for the answer. Check if the user clicked the first button (Reset) and reset the timer if so.

In the setupPrefs method, change the line self.updateFromPrefs() to:

self.checkForResetAfterPrefsChange()

Build and run the app, start the timer, go to Preferences, change the time and click OK. You will see the dialog and get the choice of resetting or not.

Dialog

Sound

The only part of the app that we haven't covered so far is the sound. An egg timer isn't an egg timer if is doesn't go DINGGGGG!.

In part 2, you downloaded a folder of assets for the app. Most of them were images and you have already used them, but there was also a sound file: ding.mp3. If you need to download it again, here is a link to the sound file on its own.

Drag the ding.mp3 file into the Project Navigator inside the EggTimer group - just under Main.storyboard seems a logical place for it. Make sure that Copy items if needed is checked and that the EggTimer target is checked. Then click Finish.

AddFile

To play a sound, you need to use the AVFoundation library. The ViewController will be playing the sound when the EggTimer tells its delegate that the timer has finished, so switch to ViewController.swift. At the top, you will see where the Cocoa library is imported.

Just below that line, add this:

import AVFoundation  

ViewController will need a player to play the sound file, so add this to its properties:

var soundPlayer: AVAudioPlayer?

It seems like a good idea to make a separate extension to ViewController to hold the sound-related functions, so add this to ViewController.swift, outside any existing definition or 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 is doing most of the work here - it first checks to see whether the ding.mp3 file is available in the app bundle. If the file is there, it tries to initialize an AVAudioPlayer with the sound file URL and prepares it to play. This pre-buffers the sound file so it can play immediately when needed.

playSound just sends a play message to the player if it exists, but if prepareSound has failed, soundPlayer will be nil so this will do nothing.

The sound only needs to be prepared once the Start button is clicked, so insert this line at the end of startButtonClicked:

prepareSound()

And in timerHasFinished in the EggTimerProtocol extension, add this:

playSound()

Build and run the app, choose a conveniently short time for your egg and start the timer. Did you hear the ding when the timer ended?

Ding

Where to Go From Here?

You can download the completed project here.

This macOS development tutorial introductory series has given you a basic level of knowledge to get started with macOS apps–but there’s so much more to learn!

Apple has some great documentation covering all aspects of macOS development.

I also highly recommend checking out the other macOS tutorials at raywenderlich.com.

If you have any questions or comments, please join the forum discussion below!

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.

Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!

Average Rating

4.3/5

Add a rating for this content

Sign in to add a rating
21 ratings

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK