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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
macOS Development for Beginners: Part 3
30 mins
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 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?
-
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.