12.
Building the User Interface
Written by Sarah Reichelt
In the previous chapter, you loaded in the movies.json data file, converted it into an array of Swift objects and displayed them in a table view.
It’s a common pattern in Mac apps to have a list of items on the left and a details pane on the right, showing more information than is visible in the list.
In this chapter, you’ll add this pattern to MoviesTable. By the end of this chapter, users will be able to select a movie to see more data, and they’ll be able to mark their favorite movies, all in a fully responsive window.
Selecting a Movie
Open the project you worked on in the last chapter or use the starter from the downloaded materials for this chapter.
Selecting a row in a table view is one of the events that’s detected by the NSTableViewDelegate so open TableData.swift and add these two method stubs:
// 1
func showSelectedMovie(_ movie: Movie) {
print("You selected \(movie.title)")
}
// 2
func clearSelectedMovie() {
print("Selection cleared")
}
You’ll add more code to these methods when you’ve set up the user interface, but for now, they’ll print reports so you know what’s happening:
- The first one gets a
Movieas the argument and prints its title. - The other method detects when the user clears the selection.
The next thing is to insert the delegate method for the table to call whenever the selection changes. Add some blank lines after the last method and start typing selection until you see these autocomplete options:
Choose tableViewSelectionDidChange(_ notification:) and replace the code placeholder with:
// 1
let row = moviesTableView.selectedRow
// 2
if row < 0 || row >= movies.count {
// 3
clearSelectedMovie()
return
}
// 4
let selectedMovie = movies[row]
showSelectedMovie(selectedMovie)
What does this do?
- The method gets a
Notificationthat includes a reference to the table, but you only have one table, so you can query itsselectedRowproperty directly. - If the user hasn’t selected anything,
rowequals-1. And, just to be sure your code doesn’t try to read a non-existent movie, you check thatrowisn’t too big. - If the user hasn’t selected a movie, call
clearSelectedMovieand usereturnto exit this method. - Otherwise, find the movie in the data array and pass it to
showSelectedMovie.
Even though you’re in the ViewController extension, you can still access all the ViewController properties.
Run the app now and select a few different movies. Check that the titles appear in the Xcode console. Command-click the selected movie to clear the selection and confirm that this prints the correct text:
Now, you can move on to showing the selected movie’s details on the right of the window.
Adding the Details Fields
Open Main.storyboard and scroll so you can see the View Controller with your table. Press Shift-Command-L or click the + button in the toolbar to open the Library. Search for box and drag a Box into the empty space. This will draw a nice border around the details display.
With the box still selected, open the Attributes inspector and set Title Position to None:
Next, you need to add some Auto Layout constraints. Click the Add New Constraints button and enter 20 for the spacing on the left. For the other three, click the little arrow in the box and select Use Standard Value. The standard value is 20 when spacing from the sides of the view, but it’s less for the spacing to another object. You want them all the same, which is why you set the left spacing manually.
When your settings look like this, click Add 4 Constraints:
You’ve now positioned your new box neatly filling the right side of the window. Look at the outline view: Box has its own View. The detail display elements all go into this subview.
You want to show the movie details in text fields, but you’re not going to let the user edit them here. Open the Library again by pressing Shift-Command-L and search for label. This is perfect for what you need:
Drag a Label from the library into the Box. Drag the side handles out until the width is 240, keeping the height at 16:
Next, drag the label towards the top right corner of the box until you see two blue guides appear. Release the mouse button when the top right of the label is at the intersection of these. Use the vertical guide inset from the edge, like in this screenshot:
Now that you’ve resized this label, you can copy it to create the other data labels at the same size. Select the original and press Command-D to duplicate. Then drag the copy down and left until you get the alignment guides at the sides and it clicks into the standard spacing from the original at the top:
Repeat this process twice more until you have four labels, all lined up.
Next, drag to select all four labels, click Resolve Auto Layout Issues and choose Reset to Suggested Constraints:
This tells Auto Layout to add the constraints it thinks are best, given your current positioning. You can see what it’s done by selecting any label and opening the Size inspector. This is the top label:
Vertically, the label has spacing to the edge of its parent view and to the top of the next label. Horizontally, it has spacing from either edge of the containing superview, and it’s aligned with some of the other labels.
Note: Because you set the width of the label to 240, this constraint might replace the horizontal leading or trailing space constraint. If you see this, don’t panic — Xcode will ask you to adjust it soon.
Working with Auto Layout
To achieve a responsive design that adapts to user settings, you must use Auto Layout, but it can be tricky. Here are some tips to make it easier to work with:
- If any change makes things look wrong, press Command-Z to undo immediately. Trying to fix a mistake is only going to lead to more problems.
- Add and position major elements first. Then, you can use them as anchors for less important elements.
- Use the alignment guides to position elements using standard spacing before adding constraints.
- Use Reset to Suggested Constraints as a starter, but have your fingers ready on Command-Z to undo if it does something unexpected.
- Leave any placeholder text visible while setting up the layout. It’s much easier to see what’s happening.
- Don’t test by resizing the view in the storyboard. Leave your storyboard view set to its minimum size and test responsiveness by resizing the window in the app.
- If everything gets in a complete muddle, use the Resolve Auto Layout Issues button to Clear Constraints from selected views or from the entire view controller.
Everyone struggles with Auto Layout at some point, but these suggestions will save you some grief.
To learn more about Auto Layout, check out our book: Auto Layout by Tutorials.
Inserting Title Fields
You’ve added the fields to show the data for each movie, so now you’ll add a title beside each one.
These are also read-only text fields, so drag a new label into the box from the Library. Open the Attributes inspector (Command-Option-5) and set Title to Title: and Alignment to Right:
The Font is set to System Regular by default. That’s perfect for the data fields, but the title fields will look better if they’re a bit different.
Click the icon of a window with a T in it, beside where it says System Regular. Open the Font menu in the popup and select Headline, then click Done:
Next, drag the new title field so that it aligns with the top and left edges of the first data label. You want to get the three horizontal guides as shown:
Again, you’ll detect a virtual click when you’re the standard distance away from the data label.
Duplicate this title label three times, dragging each one to left of the next data label. If you get the drag wrong and end up resizing the field or dragging the wrong object, press Command-Z to undo and try again. Set the titles to Run time:, Genres:, and Principals:. Because you right-aligned these titles, they expand to the left to show the longer strings, which is exactly what you want.
When you’ve finished, your labels look like this:
Constraining the Titles
You still have to set Auto Layout constraints for the titles, so select all four titles, click Resolve Auto Layout Issues and choose Reset to Suggested Constraints.
This still leaves a yellow warning symbol beside View Controller Scene in the outline view, so click it to see the problems:
There are four warnings for the titles, and each starts with Leading constraint is missing. Their problem is that the labels have a trailing constraint at the right but no constraint at the left. For each of these issues, click the yellow triangle, select Use Fixed Leading and Resizing Trailing Constraints and click Confirm:
This adds the missing leading constraint and changes the spacing between the title and the data label to greater than or equal to the standard. As the dialog says, this is the recommended option for views on the left side of the parent view. This title is to the left of the box that is its parent.
If Auto Layout set a fixed width for one of the data labels, you’ll see a fifth warning about Fixed width constraints. Click its yellow triangle and choose Set Constraint to >= Current Width:
This allows the label field to expand as much as it needs, but makes sure it can never get too narrow.
Once you’ve got rid of all the warnings, click < Structure to return to the outline view.
As a final check, run the app. Resize the window and confirm that all the labels stay aligned and positioned like this:
Connecting to the Code
You have the fields set up, but your code needs to have a way to refer to them so it can put the correct data in the correct slot.
Option-click ViewController.swift in the Project navigator to open it in a secondary editor pane beside the storyboard.
Add some blank lines after moviesTableView, then Control-drag from the top data label to this space until you see the Insert Action or Outlet tooltip:
Set the name to titleLabel and click Connect:
Repeat this for the other three data labels, setting their names to runtimeLabel, genresLabel and principalsLabel.
If the drag keeps wanting to connect to an existing outlet instead of making a new one, drag into the beginning of a line, before there’s any text.
When you’ve finished, your code looks like this:
Mouse over the black blobs in the line numbers gutter — the connected view highlights in the storyboard.
Note: You’ve created links between the storyboard and the code using exact names. Once you’ve made those connections, do not edit any names in the code or you’ll break the link and your app will crash.
If you did edit a name, the easiest fix is to undo your edit. If you prefer to replace the link, go back to the storyboard and select the connected object. Press Command-Option-7 to open the Connections inspector and find the changed one. Press the x beside the connection to delete it, then drag from the circle to connect to the new name. This applies to outlets and to actions, which you’ll encounter later in this chapter.
Now that the data labels are set up, select each one and use the Attributes inspector to clear the default Label titles. These placeholders are useful during configuration, but you don’t want them showing up in the app.
With all the connections in place, you’re ready to show some movie information.
Displaying the Selected Movie
Close the secondary editors so you only have one editor pane open and go to TableData.swift.
Replace the print line in showSelectedMovie with:
// 1
titleLabel.stringValue = movie.title
// 2
runtimeLabel.stringValue = "\(movie.runTime) minutes"
// 3
genresLabel.stringValue = movie.genres
Stepping through these lines:
- You supply
movieas an argument to this method, so you can use it to populate your new fields.NSTextFieldhas astringValueproperty for showing text. -
runTimeis anInt, so use string interpolation to convert it to aString, adding the units. -
genresis already aString, so you can use it directly.
Set clearSelectedMovie to:
titleLabel.stringValue = ""
runtimeLabel.stringValue = ""
genresLabel.stringValue = ""
principalsLabel.stringValue = ""
This puts empty strings into the four fields to clear them. You aren’t populating principalsLabel yet, but you can add the code to clear it.
Run the app and select a movie:
Test Command-clicking the selection to clear the display.
Notice what happens if you select a movie with a long title — the table truncates the title, which is what you want, but it would be better if the data views wrapped to show the full text.
Adjusting Auto Layout
Back in Main.storyboard, select the first data view. The easiest way is to click Title Label in the outline view.
In the Attributes inspector, set Layout to Wraps. This changes Line Break to Word Wrap:
The Runtime Label never has many characters, but apply the same changes to Genres Label and Principals Label.
Selecting a movie with a long title now shows a different problem: Auto Layout has aligned the title header to the bottom of the title data instead of the top:
This is the sort of layout error you get when you let Auto Layout set the constraints, but it’s still easier to accept the suggestions and tweak as needed rather than setting them all manually from the start.
To fix this misalignment, select Title Label again and open the Size inspector. In the list of Vertical constraints, find the one that sets Last Baseline Space to: Title::
The Edit button lets you adjust the constraint distance, but you need to change the constraint completely, so double-click in the box to open the Size inspector for the constraint itself:
Constraints are elements in the storyboard, just like text fields and you can edit them using the inspectors.
The important settings here are First Item and Second Item. They show the two elements connected by this constraint and the property that’s used in the constraint. Right now, they’re both set to Last Baseline so the bottom of the text on the last line of each lines up.
Click the First Item menu to see a menu with three sections. The bottom section selects the element and you can leave it unchanged. The top element is where you set the constraint property. Change it to Top:
Do the same for Second Item, setting its property to Top but keeping the element at Title Label.
Repeat this process for the other three data labels, double-clicking on the Last Baseline Space constraint and changing both items to Top. Then run the app again:
Now you’ve sorted this out, you can add in the principals display.
Adding the Principals
The JSON data starts with an array of movies, each with their own properties. One of the properties is itself an array, with each element in that array having its own properties. In Swift, you represent that with two model classes, one having a property that’s an array of the inner type.
Add a new Swift File to the project and name it Principal.swift. This gives you three files related to the models and the data, so now’s a good time to put them in their own group.
Select Movie.swift, Principal.swift and movies.json in the Project navigator. Right-click and choose New Group from Selection. Set the name of the group to Data & Models.
Open Principal.swift and add this:
// 1
class Principal: Codable {
// 2
let id: String
var name: String
var category: String
var roles: String
// 3
var display: String {
if roles.isEmpty {
return "\(name): \(category)"
}
return "\(name) as \(roles)"
}
}
This defines a new class:
-
Principalconforms toCodableso you can use it with JSON. - Checking the JSON data, you can see that each element in the
principalsarray has four properties with these names. They’re all strings andidis the only one you don’t want people to edit. - Each principal has
rolesif it represents an actor, or it has acategorylike writer. This computed property returns a valid string to describe either type.
Note: It’s possible to change property names when converting between JSON and Swift objects, but it’s much easier to keep them the same.
So far, your JSON decoding has ignored the principals. To change this, open Movie.swift.
Under the other properties, add:
var principals: [Principal]
Now the definition of each Movie includes an array of Principal objects. But this causes an error in the initializer because it isn’t setting principals.
You didn’t type the initializer in the first place, so easy come, easy go. Delete the whole init method. You could ask Xcode to generate a new initializer, but there’s another technique.
Add some blank lines after principals and type init. Autocomplete suggests several options. Choose the one with all the properties as arguments:
This fills in a new init method that includes principals. Unless your initializer does some custom setup, you can always delete it and regenerate it any time you change the properties.
These additions mean that JSONDecoder now adds principals to Movie, but you still need to supply a way to display them.
Note: You may be wondering why you didn’t have to provide an explicit initializer for
Principal. Didn’t we say earlier that classes required an initializer? Indeed they do, butCodableprovides one by default. Technically, you can omit the initializer forMoviesince it, too, conforms toCodable. But, it’s also important to understand how to get Xcode to write the code for you when you need it.
Displaying the Principals
Add this computed property to Movie:
// 1
var principalsDisplay: String {
// 2
let principalDisplays = principals.map { $0.display }
// 3
return principalDisplays.joined(separator: "\n")
}
What’s happening here?
- The computed property returns a
String. - It uses
mapto loop over the elements inprincipals, getting thedisplaycomputed property for each one. - Then, it converts the array into a single string, joining them with
\n, which is what you type to get a line feed in a string.
Now, open TableData.swift and add this line to showSelectedMovie(_:):
principalsLabel.stringValue = movie.principalsDisplay
This inserts the computed property into the final data label.
Run the app and check it out:
And there it is. Your selected movie display is fully functional. This section covered a lot of ground, so great work!
Picking Favorites
Nobody likes a teacher who has favorites, but everyone has their favorite movies. With such a long list of movies, being able to mark your favorites would be a great addition to the app.
Movie already has a Boolean property called isFav. The first step is to add the user interface for displaying and changing this.
Open Main.storyboard and scroll so you can see the top of the selected movie box. Press Shift-Command-L or click the + button to open the Library. Search for image and drag an Image Button into the top left of the box. This is an NSButton that’s pre-configured to have an image and no title.
To set the position for the new button, click Add New Constraints in the bottom toolbar. Enter 8 for the top and left spacings. then check Width and Height and enter 30 for each of them.
Notice how you have red lines for the left and top spacings and not for the right and bottom. The lines show the spacings you’re adding. Xcode turns them on automatically when you enter a value, but you can also click the red line to toggle them.
Click Add 4 Constraints to move and resize the button.
Next, Option-click ViewController.swift in the Project navigator to open it in a secondary editor.
You need to make two connections here: An outlet property that you can use to refer to the button in your code and an action method that the button can call when it’s clicked. The nice thing is that Xcode knows which one of these you’re adding, based on whether you drag to near the top of your code, or near the bottom.
Add some blank lines under principalsLabel and Control-drag from the button into this space. Xcode suggests adding an outlet. Set its name to favButton and click Connect:
This fills in one line of code:
@IBOutlet weak var favButton: NSButton!
For the action, scroll down the page and add some blank lines before the final closing curly brace. Control-drag from the button into this space. This time, Xcode offers to create an action. Set its name to favButtonClicked and click Connect:
Note: If Xcode gets it wrong, you can always swap between Outlet and Action using the menu at the top of the popup dialog.
That’s it for the storyboard, so you can close its editor now. The next step is to show a suitable image in the button for favorite and non-favorite movies.
Open TableData.swift and find clearSelectedMovie(). When the user has no movie selected, you don’t want any image appearing in the button, so add this line:
favButton.image = nil
By setting the button’s image to nil, you blank the button.
The code in showSelectedMovie(_:) is a bit more complicated. You’ll set an image depending on the value of isFav, and you want to use a colored image from SF Symbols, which is easy in SwiftUI but not quite so straightforward in AppKit.
Add this code to the end of showSelectedMovie(_:):
// 1
let imageName = movie.isFav ? "heart.fill" : "heart"
// 2
let color = movie.isFav ? NSColor.red : NSColor.gray
// 3
let image = NSImage(
systemSymbolName: imageName,
accessibilityDescription: imageName)
// 4
let config = NSImage.SymbolConfiguration(paletteColors: [color])
// 5
favButton.image = image?.withSymbolConfiguration(config)
This is a lot of code for setting a button image!
-
Use the ternary operator to get an image name depending on the value of
isFav. Both these names are from SF Symbols, or you can find them in Xcode’s Symbols Library. A favorite movie gets a filled heart and a non-favorite gets an outline. -
Get an
NSColorfor each state ofisFav— red for a favorite and gray for others. -
Create an
NSImagefrom the symbol name using the name of the image as its accessibility label. -
Create a
SymbolConfigurationfor theNSImage. This can take a palette of colors for multicolor symbols, but you only want one color, so setpaletteColorsto a single element array. -
Finally, set the button’s
imageusing the image and its configuration. The image is an optional — you might have typed in an invalid name — so optional chaining only applies the configuration if the image exists.
That covers setting the image, but now you need to write the code to let the user toggle the setting. To make this easier, add a new property at the top of ViewController.swift:
var selectedMovie: Movie?
You’ll use this to store the currently selected movie. It’s an optional because the user may not have selected any movie.
To set this property, go back to TableData.swift and add this line to clearSelectedMovie():
selectedMovie = nil
And add this to showSelectedMovie(_:):
selectedMovie = movie
Now selectedMovie reflects the user’s selection and is nil if the user hasn’t selected anything.
With that property hooked up, zip back to ViewController.swift and insert this into favButtonClicked(_:).
// 1
if let selectedMovie {
// 2
selectedMovie.isFav.toggle()
// 3
showSelectedMovie(selectedMovie)
}
How does this work?
- Check to see if there’s a value in
selectedMovie. If there isn’t, this method won’t do anything. - Toggle
isFavfor the movie. This uses a Boolean method that swaps betweenfalseandtrue. - Call
showSelectedMovie(_:)to update the display.
Time to try it out. Run the app and select a movie. Click the heart to turn it on. Select a different movie — the heart goes off. Go back to the first movie and your heart appears again.
For the moment, your favorite selections aren’t stored anywhere, so when you quit the app, you lose all your hearts. :[ In the next chapter, you’ll learn how to save and restore the edited data.
Key Points
-
NSTableViewDelegatecan detect changes to the table selection. - Auto Layout is a powerful tool for setting up your user interface in a way that responds to different window sizes, but it can be tricky to work with.
- JSON data can include nested data types.
- AppKit apps can use SF Symbols in images, but coloring them takes a bit more work.
Where to Go From Here
You’ve created a table and filled it with data. Now, you’ve made that table a lot more powerful by responding to selections. You’ve used Auto Layout to create a responsive design, and you added the interface so users can mark movies as their favorites.
In the next chapter, you’ll move on to powering the table itself, not only its selections. You’ll add searching and sorting, and you’ll implement data storage to save and restore user edits.