Update note: This tutorial was updated for Swift by Joe Howard. Original tutorial by Tutorial Team Member Ernesto García.
Welcome back to the third and final part of the How to Create a Simple Mac App tutorial series!
In the first part of the series, you created a Mac app that showed a list of Scary Bugs.
In the second part of the series, you learned how to show the details for the bugs, as well as how to add, delete and modify bugs.
In this third and final part of the tutorial, you’ll wrap up your OS X and Swift introduction by polishing your app and providing a better user experience.
By the time you’re done with this tutorial, you will have created a complete OS X app – and hopefully you’ll be inspired to create some Mac apps of your own! :]
If you didn’t follow along with the previous parts, here’s an archive of the final project from part 2 that you can open in Xcode and follow along from here!
What’s wrong with this app?
Everything works great so far. You can see a bunch of scary bugs, add or delete bugs, and you can even change any information on a bug.
It’s functionally complete. But as is, you are not providing a very good user experience.
For example, if you resize the window and make it very large, look what happens to the poor controls!
They are not resized, and they are totally misaligned, making the app look very ugly and unprofessional.
It’s even worse if you make your window small:
Ack! In this case you cannot even see all the information you need. It’s clear that you need to set a minimum window size to make the app usable.
Another issue is that bug data doesn’t persist between app sessions. Any bug data added or modified by the user of your app is lost every time the app is restarted. You’ll add bug data persistence later on in this part of the series.
So, let’s fix the resizing issues by setting a minimum window size, after first adding a few more UI elements.
Open MasterViewController.xib. Resize the view and arrange the controls in a way that you feel looks good and such that you can see the information you need with the minimum size, perhaps like this:
In the sample screenshot, the controls are also arranged so that all the buttons are aligned vertically, and all the controls in the detail view are aligned horizontally and have the same width (except the Change Picture button).
Now let’s add a little detail to make it look better, and to make the separation between the list and the detail section clearer. Select Vertical Line in the Objects Library, and drag it onto the view. Place it between the list and the detail controls, just in the middle of the empty space.
That looks much better!
Reset
Also, it would be nice to have a button to reset bug data to the original sample data supplied in the app, after the user has made a few changes. Drag a push button to below the table view, change it’s title to Reset All, and, similar to what you did in Part 2, control-drag from the button to MasterViewController.swift in the Assistant Editor to add an action named resetData:
Add the following code to resetData():
setupSampleBugs() updateDetailInfo(nil) bugsTableView.reloadData() |
The method here just calls setupSampleBugs()
to restore the sample app data. updateDetailInfo
with the nil parameter will clear out the details fields, and then you just reload the table view.
Build and run the application, then add, delete or modify some bug data. Then, click the Reset button to ensure it is functioning correctly.
Resizing
After these changes, look in the size inspector and record the size of the main Custom View in MasterViewController.xib. In my case, it was 540×400 pixels, but yours may vary. This is OK, just write down what it is for you.
That’s going to be the minimum size for your application window. Now, open MainMenu.xib, and then select the window of the app. In the size inspector check the Constraint box for Minimum Size and change the width and the height fields to the values you wrote down earlier.
Build and run the application, and try resizing the window:
You’ll see you can still resize the window, but it won’t get any smaller than the minimum size you defined. With this change, your bug information is always properly visible, w00t!
Now it’s time to handle the resizing, which requires a bit of thought. Your window has two different parts: the table view, and the details section. They should behave differently when you resize the window.
First you need to ensure that the MasterViewController view itself resizes correctly when the app window is resized. Remember, the window and the view controller’s view are two separate things! You’ll do that using the Auto Layout Visual Format Language. Open to AppDelegate.swift, and add the following to the end of applicationDidFinishLaunching():
// 3. Set constraints on masterViewController.view masterViewController.view.translatesAutoresizingMaskIntoConstraints = false let verticalConstraints = NSLayoutConstraint.constraintsWithVisualFormat("V:|[subView]|", options: NSLayoutFormatOptions(0), metrics: nil, views: ["subView" : masterViewController.view]) let horizontalConstraints = NSLayoutConstraint.constraintsWithVisualFormat("H:|[subView]|", options: NSLayoutFormatOptions(0), metrics: nil, views: ["subView" : masterViewController.view]) NSLayoutConstraint.activateConstraints(verticalConstraints + horizontalConstraints) |
Adding those constraints programmatically ensures that the MasterViewController view will resize when the window is resized. For both the vertical and horizontal dimensions, the master view controller will be right up against the bounds of the window.
Inside the view, you want the table view to grow vertically when the window is resized, but you want its width to stay constant. However, you want the detail side to expand as the window grows larger. You’ll handle the resizing by using auto layout in interface builder to constrain the views.
Open MasterViewController.xib and select the table view. Click the Pin constraint popup in the bottom right of the Interface Builder editor window, and add top, left, and bottom constraints and a width constraint, being sure to to set the bottom constraint against the custom view in the drop down menu, then tap “Add 4 Constraints”:
Note that your constraint values might be different from those pictured here since the exact spacing might be different.
Next, select the Reset button, and add a top constraint to the table view and a left constraint to the main view:
Next select the separator line that you added, and set a top and bottom constraint to the main view, and a left constraint to the table view, being sure to select the “Bordered Scroll View – Table View” in the left constraint drop down:
Now you need to configure the constraints for the “Add” and “Delete” buttons. You don’t want these buttons to change their size, but you need to keep their distance to the bottom of the table view constant. Add top, left, width, and height constraints for each button individually, for example, for the add button:
Repeat for the delete button.
Build and run the application, and try resizing the window. Great success!
Now you can resize the window, and the table view grows vertically to fit it. The buttons also change their position to always stay below the table view.
But the details section is not looking good yet. In the details section, you need the controls to resize horizontally when the window’s width increases. The Auto Layout constraints for the detail views can be set in a similar manner to the table view. Add the following constraints to the details views in Interface Builder, in the following order:
- Top and left constraints for the Name label
- Top, left, and right constraints for the
bugTitleView
text field - Top and left constraints for the Rating label
- Top, left, right, and height constraints for the
bugRating
custom view text field - Top, left, bottom, and right constraints for the
bugImageView
- Move the Change Picture button so that its right edge aligns with the right edge of the
bugImageView
, then add right and bottom constraints to the button
You may notice some auto layout warnings on bugImageView
after you set its constraints, but the warnings will be resolved when the constrains are set on the Change Picture button.
After the above list is complete, the constraints on the detail views will look as follows:
Compile and run the application, and try resizing again.
If you resize the app, you will see the controls grow to fit the available space, and change their position accordingly. Now it’s looking much better!
You can experiment with the different Scaling settings on the bugImageView
. Select the Image Well in Interface Builder, and see what happens when you run the app and resize the window when choosing various options for the scaling attribute, e.g. “Proportionally Up or Down” or “Axes Independently”.
Attention to details
Now the app is working fine, and it’s able to adapt to window size changes. The user interface looks better and more professional than the previous one.
There are still a few small details that can make the user experience a little better. For example – build and run the application, and without selecting anything, click the “Delete” or the “Change Picture” buttons. You can click them, but nothing happens, right?
Since you’re the developer of the app, you know that those buttons don’t do anything when there is no selection. But the user might not know that, so the following situation could occur:
This is the kind of situation you should avoid by enabling and disabling the button as required so that your users have a better experience. These small details add up, and fixing them will make your app look polished.
Here’s what should happen whenever the selection changes:
- If a row is selected, you need to enable the “Delete” button, the “Change picture” button, the text field and the rating view.
- If the table does not have any selection, you just need to disable them, so that the user cannot interact with those controls.
In Interface Builder, you’re going to set the buttons and text field to disabled by default. Open MasterViewController.xib. Select the “Delete” button, and open the Attributes Inspector. Scroll down until you find the property “Enabled” and uncheck it.
Repeat this for the Change Picture button, and the text field.
This way, those controls are disabled by default when the application starts. You’ll need to re-enable them when the user selects a row in the table view.
In order to enable those controls, you need to add outlets in the view controller. Let’s do it first with the Delete button.
Bring up the Assistant Editor and make sure it’s displaying MasterViewController.swift.
Select the “Delete” button, and control-drag from the button into MasterViewController.swift.
A popup will appear allowing you to hook the NSButton
up to a property in your class. Make sure the connection property in that popup is “Outlet”, name it deleteButton, and click Connect.
Repeat the same operation for the Change Picture button, and name it changePictureButton.
Open MasterViewController.swift, and add the following code in tableViewSelectionDidChange(_:), just below the line updateDetailInfo(selectedDoc)
:
// Enable/disable buttons based on the selection let buttonsEnabled = (selectedDoc != nil) deleteButton.enabled = buttonsEnabled changePictureButton.enabled = buttonsEnabled bugRating.editable = buttonsEnabled bugTitleView.enabled = buttonsEnabled |
In this code, you determine if the controls need to be enabled or not based on the user selection. If the selectedDoc
is nil
, it means that no rows are selected so the controls should be disabled.
Likewise, if the user selects a bug, they should be enabled again.
There is one more step. The rating view is enabled by default, and you also want it to be disabled when the application starts. Since it’s a custom view, it cannot be enabled/disabled in Interface Builder.
You need to disable it programmatically when the application starts. Find loadView()
and change the line:
self.bugRating.editable = true |
to this:
self.bugRating.editable = false |
With this change, you set the control as not editable by default. The control is shown, but the rating cannot be changed unless the user selects a bug.
Build and run the application.
When the application starts, you can see that the controls are disabled. When you select a bug, they’re all enabled.
And when the row is deselected or deleted, they become disabled, because there are no bugs selected.
Note: You could have also solved this problem by making the all the detail views hidden until you select a bug. It’s up to your personal preference and what works best for your app.
Saving the Bugs
Your app is functioning great, handling window resizing correctly, and giving a consistent user interface to the user. However, if the user adds, removes, or edits bug data in the app and then quits, those changes are lost between sessions, and the user has to start all over again.
In order to resolve this, changes made to the bug data must be persisted to disk. There are a number of techniques to persist data, and we’ll consider a few before settling on an efficient method for this simple app.
One option would be to save data for each bug to a file on disk. When the app starts, all the bug files would be read to create the bug array. Another option would be to store bug data in the cloud, using CloudKit or a third-party API.
If the app were to hold a large amount of bug data, you would need to consider something like Core Data to persist the bug information.
Since this app is relatively simple and only contains data for a small number of bugs, you’ll take a more basic approach. Like iOS, Mac apps have access to NSUserDefaults
, so you’ll store the bug data there.
Before doing so, you have to make the bug model classes conform to the NSCoding
protocol. Start with ScaryBugData.swift by adding the following extension to the end of the file:
// MARK: - NSCoding extension ScaryBugData: NSCoding { func encodeWithCoder(coder: NSCoder) { coder.encodeObject(self.title, forKey: "title") coder.encodeObject(Double(self.rating), forKey: "rating") } } |
Here, you’re setting the protocol conformance and implementing encodeWithCoder
, which writes the important data of the object out into a coder object.
You also need a matching initializer. However, you cannot put required initializers in a class extension so add it to the main class definition instead:
required convenience init(coder decoder: NSCoder) { self.init() self.title = decoder.decodeObjectForKey("title") as String self.rating = decoder.decodeObjectForKey("rating") as Double }
init(coder:)
will do the opposite of encodeWithCoder
, and read in the object from a coder.
Similarly, add the NSCoding
protocol to ScaryBugDoc.swift with the following extension:
// MARK: - NSCoding extension ScaryBugDoc: NSCoding { func encodeWithCoder(coder: NSCoder) { coder.encodeObject(self.data, forKey: "data") coder.encodeObject(self.thumbImage, forKey: "thumbImage") coder.encodeObject(self.fullImage, forKey: "fullImage") } } |
Then add the required initializer to the main class definition:
required convenience init(coder decoder: NSCoder) { self.init() self.data = decoder.decodeObjectForKey("data") as ScaryBugData self.thumbImage = decoder.decodeObjectForKey("thumbImage") as NSImage? self.fullImage = decoder.decodeObjectForKey("fullImage") as NSImage? } |
So you’ve setup the model objects to be encoded and decoded. Now you need to implement saving them to NSUserDefaults
. Start by adding the following helper method to MasterViewController.swift:
func saveBugs() { let data = NSKeyedArchiver.archivedDataWithRootObject(self.bugs) NSUserDefaults.standardUserDefaults().setObject(data, forKey: "bugs") NSUserDefaults.standardUserDefaults().synchronize() } |
This method creates an NSData
object from the bugs
array and then saves that object into NSUserDefaults
. NSKeyedArchiver
can handle the model objects in the bugs
array, since they conform to NSCoding
.
Switch to AppDelegate.swift, and add the following to applicationWillTerminate():
masterViewController.saveBugs() |
Prior to the application terminating, MasterViewController will save the bug
array to NSUserDefaults
.
Loading the Bugs
Now that you have a means of saving bug data, you must read the data in when the app starts. Still in AppDelegate.swift, find applicationDidFinishLaunching
and the following line of code inside it:
masterViewController.setupSampleBugs() |
Replace that line with the following:
if let data = NSUserDefaults.standardUserDefaults().objectForKey("bugs") as? NSData { masterViewController.bugs = NSKeyedUnarchiver.unarchiveObjectWithData(data) as [ScaryBugDoc] } else { masterViewController.setupSampleBugs() } |
You first check to see if bug data exists in NSUserDefaults
. If so, even if it’s an empty array (hey, maybe the user hates bugs!) the restore that data into the bugs
array. Otherwise, load the the sample bug data instead.
Build and run the application, add/edit/delete bugs, and then quit the app using Cmd-Q. Restart the app and you’ll see that all the changes to bugs have been saved across restarts! :]
Note: If you doesn’t quit the app gracefully, then the call to saveBugs()
may not happen — you’ll need to hit Command-Q rather than kill the app from Xcode. To counteract this problem, you can add more tactical calls to saveBugs()
in other spots in MasterViewController – say whenever there’s a new bug or a change to an existing one.
Where To Go From Here?
Here is the final project with all of the code you’ve developed in this tutorial series.
At this point I’d recommend reading Apple’s Mac App Programming Guide, or having a look at some of the samples provided by Apple.
You can also try to add different kinds of controls or new functionality to the application. For instance, how about writing the model to a file, and asking the user where to store it using a file save panel? Or maybe add the ability to search a bug in your list, using a search field control?
I hope you enjoyed making this Mac version of the ScaryBugs app using Swift! If you have any questions or comments or would like to see more Mac tutorials on this site in the future, please join the forum discussion below!
Getting Started With OS X and Swift Tutorial: Part 3/3 is a post from: Ray Wenderlich
The post Getting Started With OS X and Swift Tutorial: Part 3/3 appeared first on Ray Wenderlich.