原文地址 翻译:DeveloperLx
Welcome back to the final and third part in our 3-part macOS Development tutorial for beginner series!
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!
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.
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.
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.
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.
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.
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.
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.
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.
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?
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!