macOS Development for Beginners: Part 3
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.
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.
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.
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.
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:
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?
-
startTime
is anOptional Date
- if it isnil
, the timer cannot be running so nothing happens. - 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. - Calculate the seconds remaining for the timer, rounded to give a whole number of seconds.
- If the timer has finished, reset it and tell the
delegate
it has finished. Otherwise, tell thedelegate
the number of seconds remaining. Asdelegate
is an optional property, the ? is used to perform optional chaining. If thedelegate
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?
-
startTimer
sets the start time to now usingDate()
and sets up the repeatingTimer
. -
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. -
stopTimer
stops the repeating timer. -
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.
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.
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?
-
A computed variable called
selectedTime
is defined as aTimeInterval
. -
When the value of the variable is requested, the
UserDefaults
singleton is asked for theDouble
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 ofselectedTime
. -
If
selectedTime
has not been defined, use the default value of 360 (6 minutes). -
Whenever the value of
selectedTime
is changed, write the new value toUserDefaults
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:
-
Ask the prefs object for its
selectedTime
and convert it from seconds to whole minutes. - Set the defaults to "Custom" in case no matching preset value is found.
-
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. -
Set the value for the slider and call
showSliderValueAsText
. -
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() }
- 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.
- Whenever the slider changes, update the text.
- Clicking Cancel just closes the window but does not save the changes.
- 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.
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.
-
Preferences
is a struct, so it is value-based not reference-based. Each View Controller gets its own copy. -
The
Preferences
struct interacts withUserDefaults
through a singleton, so both copies are using the sameUserDefaults
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
.
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?
- If the timer is stopped or paused, just do the reset without asking.
-
Create an
NSAlert
which is the class that displays a dialog box. Configure its text and style. - 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.
- 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.
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.
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?
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!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK