This article has been translated from Raywenderlich.com’s classic MacOS development tutorial. You can translate up to 10 articles on Raywenderlich.com. Translating it is just because the dormitory is too noisy and hot. Only in this way can I finish reading one sentence by one and take my notes. I hope you can read the English original if you have English reading ability, after all, whether Xcode, or official documents, or all kinds of cutting-edge information are only in English version. To sum up, this translation version is for reference only, declined to reprint.

Links to zero based macOS application development: (a) the source/zero basis macOS application development (2) : the original/zero basis macOS application development (3) : the original/(this paper)

Welcome back to the final part of our three-part zero-based MacOS application development tutorial!

In Part 1, you learned how to install Xcode and create a sample app; In the second part you create the UI for a more complex app, but it doesn’t work yet because you haven’t written any code. In this section, you will write all the Swift code and make your app come alive!

start

If you haven’t finished Part 2 yet, or you’d like to continue learning from a more pure situation, you can download the project file for the completed UI layout in Part 2. Open the project file you downloaded or you followed through with Part 2 and run it to make sure all of the UI displays correctly. Open the Preferences window to see if it displays properly.

Sandbox mechanism

Before you start coding, take a moment to understand the MacOS sandbox mechanism. If you’re an iOS developer, you already know this concept, if you haven’t, read on.

A sandbox app has its own storage space, and the sandbox prevents your app from accessing files created by another app, as well as other permissions and restrictions. For iOS apps, using the sandbox is a must, but for MacOS apps, it’s only an option. But if you want to distribute and sell through the Mac App Store, your App has to be sandboxed, and due to the limitations of the sandbox, your App can have some problems.

To enable the sandbox for your app, select the Project file in the Project Navigator, the blue icon at the top of the list of files. Select EggTimer in the Targets list and click on the Capabilities TAB in the top TAB. Click on the switch in the App Sandbox section. This view will expand and show you the many permissions that your app can apply for. The apps in this example don’t require any special permissions, so they don’t need to be opened.

Manage your files

Take a look at your Project Navigator. With all the files stacked together and lacking organization, the app won’t have a lot of files, but it’s always a good habit to keep your files in order and help you locate the files you need more quickly. This is especially useful for large projects.

Hold down Shift and click on the two View Controllers files separately, select them both, right-click and select New Group From Selection. Name the New Group View Controllers.

This project will contain some Model files, so right-click on the EggTimer Group, select New Group, and name the Group Model**.

Finally, select info.plist and eggtimer.entitlements and throw them away in a folder called Supporting Files.

Drag groups and files to adjust their order until your project looks like this:

MVC

The app will apply the MVC pattern: Model View Controller.

Translator’s Note: See the Wikipedia entry for MVC Design Patterns and this brief book article. -Sheldon: Well, the Model, the View Controller, the Delegate, and the Protocol

The first Model object that we’re going to create for our app is called eggTimer. This class will have some properties about the start time of the timer, the length of the countdown, and the past time. There is also an object called a Timer, which is activated every second and updates its state, using its own methods to start, pause, resume or reset the EggTimer to zero.

The EggTimer Model class also saves data and performs actions, but it cannot be used to display data. The Controller (in this case the ViewController) can communicate with the EggTimer (also known as the Model), and it has a View and uses it to display data.

In order to communicate with the ViewController, an EggTimer uses a Delegate Protocol that sends a message to its Delegate whenever some data changes. The ViewController makes itself the so-called delegate of the EggTimer, so it can receive the message and display the new data on the interface.

Write the EggTimer class

Select the Model group in the project navigator and click File → New → File… , select MacOS → Swift File, and click Next, name the File EggTimer. Swift and click Create to Create it.

Add the following code to this file:

class EggTimer { var timer: Timer? = nil var startTime: Date? Var duration: TimeInterval = 360 var duration: TimeInterval = 0}

The EggTimer class and its properties are now set. TimeInterval is actually a Double, but we usually use it instead of a Double for seconds.

The second thing is to add two Computed Properties to the class, which are used as a shortcut to determine the EggTimer Properties. Write the following code after the property you just added:

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

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

Add the definition of the proxy protocol outside the EggTimer.swift file of the EggTimer class — I prefer to write the proxy protocol at the top of the file after the import section.

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

You can read this as: this protocol establishes a contract that any object declaring compliance with the EggtimerProtocol (that is, signing the contract) is required to implement both methods.

Now that you have defined a protocol, the EggTimer can enforce this protocol by defining a delegate property, which can be of Any type (Any). EggTimer does not know or care what the type of the agent is, because it is obvious that since the agent is derived from the EggTimerProtocol protocol, it has both methods.

Add these code attributes to the EggTimer class:

var delegate: EggTimerProtocol?

Getting the EggTimer’s timer object to run causes a method to be called every second. Go ahead and add the following code to define this method. The dynamic keyword is the key to allowing the Timer to discover 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 exactly is this code doing?

  1. startTimeIt’s optionalDateWhen it isnilWhen, the timer will not be able to run, so nothing will happen;
  2. recalculateelapsedTimeProperties,startTimeIt was earlier than the current time, sotimeIntervalSinceNowIt’s going to produce a negative value, and that negative value is going to makeelapsedTimeTo be a positive value;
  3. The remaining time of the timer is calculated and rounded;
  4. If the timer has expired, it is reset and notifieddelegateThe timer is over; Otherwise, telldelegateHow many seconds are left on the timer? In addition, becausedelegateIs an optional value, so we need to use?To unpack, that is, ifdelegateIt’s not assigned yet, and nothing bad is going to happen except that those methods are not going to be called.

You will see that Xcode tells you that we have some errors, but when we finish coding the EggTimer class, they will disappear because we haven’t added methods for the start timer, pause timer, resume timer, and restart 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 and reset all properties of the timer? .invalidate() timer = nil startTime = nil duration = 360 elapsedTime = 0 timerAction() }

What does this code do?

  1. By calling theDate()methodsstartTimerSet the start time to the current time, and then it will set one to run repeatedly all the timeTimer;
  2. resumeTimerIs the method that is called when the timer has paused and needs to continue, and resets the start time based on how much time has elapsed.
  3. stopTimerA timer that stops running repeatedly;
  4. resetTimerThe timer is stopped and the associated properties are restored to their original Settings.

Each of the above methods calls the TimerAction, so once they are called, the content displayed on the screen is updated.

ViewController

Now that the EggTimer object is up and running, it’s time to go back to the ViewController.swift to make changes to the data immediately visible to the interface.

The ViewController already has the @IBOutlet property, but now you need to make it have a property of type eggTimer:

var eggTimer = EggTimer()

Replace the comment line in the viewDidLoad method with this line:

eggTimer.delegate = self

An error will occur after writing the above code because the ViewController is not yet compliant with the EggTimerProtocol protocol. When we want a class to conform to a protocol, your code will look much cleaner if we create a separate Extension to hold the methods required by the protocol. Enter the following code somewhere outside the ViewController class:

extension ViewController: EggTimerProtocol {

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

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

So we need to add another Extension to the ViewController to hold the methods on the screen.

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 a Private method to retrieve Text and images based on the remaining time and display them in the Text Field and Image View on the interface.

TextToDisplay formats the rest of the time into “minutes: seconds.” ImageToDisplay calculates the percentage of hard-boiled eggs and then selects the appropriate imageToDisplay on the screen.

So the ViewController uses an eggTimer object method to receive the data from the eggTimer and display it on the screen, but the buttons on the interface have no actual code yet. In Part 2, you set up the @IBAction button.

Here are the methods for IBActions that you can use to replace the previous IBActions.

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

The three IBActions here will call the eggTimer method you added earlier.

Now compile and run your app and click the Start button. You can also use the Timer menu to control the app, and try using keyboard shortcuts to navigate your app.

Now there are a few features to improve: The Stop and Reset buttons are always disabled, and you can only set the time for 6 minutes.

If you’re patient enough, you’ll see the egg change color over time, and a “Done!” will appear when you’re DONE. .

Buttons and menus

Buttons on the screen and menu items in the menu should be enabled or disabled automatically with the state of the timer.

Add this method to the ViewController Extension used to display the associated method:

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 method uses the eggTimer state (remember the calculation property you added to the eggTimer) to figure out which button should be enabled.

In Part 2, you created a Timer Menu item as an AppDelegate property, so we should edit the code in the AppDelegate.

Switch to AppDelegate.swift and add this method:

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

In order to make your app you can first start automatically configure button when enabled, the code is added in the applicationDidFinishLaunching method:

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

The state of the EggTimer changes whenever the user presses any button or menu item, and the state of the button or menu item needs to be updated accordingly. Go back to ViewController.swift and add this line to the IBAction method of the three buttons:

configureButtonsAndMenus()

Compile and run your app again, and you can see that the buttons are enabled and disabled as expected. Click on the menu items in the menu to try them out. They should have the same function as a button.

Preference window

The app has another important question: What if you want your eggs to take less than six minutes to boil?

In Part 2, you’ve designed a preferences window that allows the user to select the countdown time they want. This window is controlled by the PrefsViewController, but it also requires a Model object to process and query the data.

User Settings can be stored in something called UserDefaults, which stores bits and pieces of data in the Preferences folder in your app’s sandbox container using key-value pairs.

In the Project Navigator, right-click the Model group and select New File… on the Xcode menu. , select MacOS → Swift File, then 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. It defines a nameselectedTimeTimeIntervalCalculate attributes;
  2. When other code requests access to the value of this variable,UserDefaultsThe singleton will look for the corresponding key “selectedTime”DoubleValue; If this value has never been defined,UserDefaultsWill return 0; But if the value exists, and it’s greater than 0, it returns the value and sets it toselectedTime;
  3. ifselectedTimeBefore it is defined, the default value is 360 (6 minutes);
  4. As long asselectedTimeThe new value is stored with the key “selectedTime”UserDefaults.

By using getters and setters, data storage in UserDefaults will be done automatically.

Now switch back to the PrefsViewController. Swift, and we need the user to modify the setting of the content displayed on the interface.

The first step is to add this code under IBOutlet:

var prefs = Preferences()

In this step you create an instance of Preferences, so you now have free access to the selectedTime evaluation variable.

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

It’s like a big chunk of code, right? ️… So let’s look at it bit by bit:

  1. accessprefsThe object’sselectedTimeProperty and convert it to an integer number of minutes;
  2. Set the default timing to “Custom” in case the default data is not found;
  3. traversepresetsPopupAnd check their tag. Remember in Part 2 you set the tag of each item to the number of minutes for each option? If a menu item selected by the user is found, the menu item is enabled and the loop is broken;
  4. Sets the value of the slider and callsshowSliderValueAsTextMethods;
  5. showSliderValueAsTextAdd “minute” or “minutes” to the number and display it in the Text Field on the screen.

Now add this line of code to viewDidLoad:

showExistingPrefs()

This method is called when the View is loaded to load the user’s Settings into the interface. In MVC mode, the Preferences Model has no idea how the data it is standing on will be displayed — the display of the interface is the responsibility of the PrefsViewController.

So, even though your app can now display the time set by the user, the preferences drop-down box still doesn’t work, and you need to write a method for it to store the new Settings and tell all relevant objects that the data has changed.

In the EggTimer object, you use the delegate mode to pass the data to where it is needed. This time, you need to notify everyone that the data has changed by sending a Notification (a delegate would be fine, though). This is just to demonstrate the use of Notification). Any object that indicates its interest in the notification can receive the notification and act upon it.

Add the following methods to the PrefsViewController:

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

So this method is going to take the value of the CustomSlider slider, convert it to minutes, and assign it to selectedTime, because the setter that we wrote before, it’s going to automatically use UserDefaults to store the new data. A notification called “PrefsChanged” is then sent out by the NotificationCenter.

Next, let’s make the ViewController receive the Notification and take action:

The last piece of code to write in the PrefsViewController is to add the actual 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 the user selects a new menu item in the drop-down box, this code checks to see if the item is Custom:

    • If it is, enable the slider and terminate the method directly;
    • If not, the tag of the item is used to retrieve the timing selected by the user.
  2. Update the text on the interface whenever the slider data is updated;
  3. Clicking the Cancel button closes the window and does not store data;
  4. Clicking the OK button will call firstsaveNewPrefs“And close the window.

Compilate and run your app. Go to Preferences, try selecting the different options in the dropdown box, and see if the slider and text appear correctly according to your choice. Select the Custom option, then select a time, click OK, and then go to Preferences again to see if the time you just selected still works.

Now try exiting your app and re-opening it, go back to Preferences, and see if your app saves your Settings.

Let the user’s Settings take effect

The preferences window looks pretty good for now — it stores and reads the user’s Settings, but when you go back to the main window, you’re still looking at 6 minutes! ☹ ️

So you need to edit the ViewController.swift to use the stored data and listen for notifications of data changes to update or reset the Timer.

Add this Extension to the ViewController.swift outside of the class definition – it will make our code look cleaner by breaking it up into parts that do different things.

Extension viewController {// MARK: - set 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 code will give an error because the ViewController doesn’t have an object called prefs inside it yet. In the ViewController class definition (where you defined the EggTimer), add this line:

var prefs = Preferences()

Now there is a prefs property inside both the PrefsViewController and the ViewController — is that a problem? No! Here’s why:

  1. PreferencesIs a struct, so it’s a data object rather than a relational object. Each View Controller can have a copy of it;
  2. PreferencesThe structure is usedUserDefaultsSo these two copies are actually calling the same oneUserDefaults, so you get exactly the same data.

In the viewDidLoad method at the end of the ViewController, add this line of code that sets itself up to connect to the Preferences:

setupPrefs()

Now there is a final series of steps to be taken. Previously, the default time, 360 seconds, was coded directly (that is, hard-coded, hard-coded), but now that the ViewController has access to the Preferences, you will need to modify it.

Find “360” in ViewController.swift (you should be able to find 3 360’s) and change them to prefs.selectedTime.

Compile and run your app. If you have previously changed the timing in the Settings, the time you selected should now appear properly on the screen. Go to Preferences, select another time, and click OK — since the ViewController receives the notification, your newly selected time should be displayed immediately.

Start the timer, then go to Preferences. In the main window, the countdown continues, change a time and click OK. The timer applies the new time but also stops and resets the countdown. I see no problem with that, but wouldn’t it be nice to add a prompt asking if the user really wants to stop the timer?

In the Extension that handles Settings in the ViewController, add this code:

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 does this code do?

  1. If the timer has stopped or paused, change the time without doing anything.
  2. To create aNSAlert, which is a class that displays a dialog box and sets its text and appearance;
  3. Add two buttons: Reset and Cancel. These will appear in the dialog box from right to left in the order you added them. The right button will be the default option.
  4. The warning is displayed in a modal window and waits for the user to select it. If the user hits the first button (Reset), the timer is Reset.

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

self.checkForResetAfterPrefsChange()

Compilate and run your app, start timing, go to Preferences, change the time, and click OK. You will see a dialog asking you if you want to reset the time.

sound

The only feature left unfinished in the app is the sound. Would an egg timer be an egg timer without a ding?

In Part 2, you’ve downloaded a folder containing all your assets, mostly images, which you’ve already used, but there’s also a sound file: ding.mp3. If you can’t find this file, you can download the audio file separately.

Drag ding.mp3 under the EggTimer group in the Project Navigator — it seems like a good idea to put it right under main.storyboard. Check Copy items if needed, check the EggTimer in Add to Targets and click Finish.

You need a library called AVFoundation to play sounds. When the agent tells the ViewController that the timer has ended, the ViewController will be responsible for playing the sound, so we switch to ViewController.swift, At the top you’ll see that this file references the Cocoa library.

At the bottom of the quote line, add:

import AVFoundation

The ViewController needs an AVAudioPlayer to play sounds, so let’s add a property to it:

var soundPlayer: AVAudioPlayer?

We should create a separate Extension for the ViewController to handle the sound-related methods, so add the ViewController.swift class definition somewhere else:

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

The prepareSound method takes care of most things — it first checks to see if the ding.mp3 exists in the app’s package, and if it does, it tries to instantiate an AVAudioPlayer with the file’s URL and make it ready for playback. This will pre-load the audio file so it can be played immediately if needed.

If SoundPlayer exists, playSound calls its play() method; But if prepareSound fails, SoundPlayer will be empty (nil), so it will do nothing.

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

prepareSound()

In the timerHasFinished method of the EggTimerProtocol Extension, append this line of code:

playSound()

Compile and run it, select a shorter time and start timing it. A crisp “Ding?” It goes off at the end of the timer.


What to do now?

You can download the source code for this project.

In this MacOS development tutorial, you’ve got the basics of developing MacOS apps, but there’s a lot more to learn!

Apple has written a lot of great documentation that covers every aspect of MacOS development.

I also strongly recommend you check out our other MacOS tutorials at raywenderlich.com.

If you have any questions, please join us in the discussion below!