Chapters

Hide chapters

macOS Apprentice

First Edition · macOS 13 · Swift 5.7 · Xcode 14.2

Section II: Building With SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

Section III: Building With AppKit

Section 3: 6 chapters
Show chapters Hide chapters

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:

  1. The first one gets a Movie as the argument and prints its title.
  2. 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:

selection autocomplete
selection autocomplete

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?

  1. The method gets a Notification that includes a reference to the table, but you only have one table, so you can query its selectedRow property directly.
  2. If the user hasn’t selected anything, row equals -1. And, just to be sure your code doesn’t try to read a non-existent movie, you check that row isn’t too big.
  3. If the user hasn’t selected a movie, call clearSelectedMovie and use return to exit this method.
  4. 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:

Selecting and deselecting.
Selecting and deselecting.

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:

Turning off the box title.
Turning off the box title.

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:

Setting box constraints.
Setting box 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:

Label in Library
Label in Library

Drag a Label from the library into the Box. Drag the side handles out until the width is 240, keeping the height at 16:

Resizing the label.
Resizing the label.

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:

Aligning the label.
Aligning the label.

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:

Position the duplicate.
Position the duplicate.

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:

Reset to Suggested Constraints
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:

Suggested constraints
Suggested constraints

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.

Suggested constraints: alternative
Suggested constraints: alternative

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:

Configuring the Title label.
Configuring the Title label.

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:

Setting the font.
Setting the font.

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:

Aligning the title.
Aligning the title.

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:

Box labels
Box labels

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:

Layout issues
Layout issues

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:

Fixing constraint issues.
Fixing constraint issues.

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:


Fixing the width constraint.
Fixing the width constraint.

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:

Testing the constraints.
Testing the constraints.

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:

Connecting the label to code.
Connecting the label to code.

Set the name to titleLabel and click Connect:

Setting the label name.
Setting the label name.

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:

Connected outlets
Connected outlets

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:

  1. You supply movie as an argument to this method, so you can use it to populate your new fields. NSTextField has a stringValue property for showing text.
  2. runTime is an Int, so use string interpolation to convert it to a String, adding the units.
  3. genres is already a String, 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:

Selecting a movie.
Selecting 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:

Wrap settings
Wrap settings

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:

Misaligned text
Misaligned text

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::

Last Baseline Space
Last Baseline Space

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:

Constraint attributes
Constraint attributes

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:

Aligning the tops.
Aligning the tops.

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:

Correct wrapping and alignment.
Correct wrapping and alignment.

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:

  1. Principal conforms to Codable so you can use it with JSON.
  2. Checking the JSON data, you can see that each element in the principals array has four properties with these names. They’re all strings and id is the only one you don’t want people to edit.
  3. Each principal has roles if it represents an actor, or it has a category like 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:

init autocomplete
init autocomplete

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, but Codable provides one by default. Technically, you can omit the initializer for Movie since it, too, conforms to Codable. 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?

  1. The computed property returns a String.
  2. It uses map to loop over the elements in principals, getting the display computed property for each one.
  3. 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:

Details display
Details display

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.

Setting button constraints.
Setting button constraints.

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:

Connecting an outlet.
Connecting an outlet.

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:

Connecting an action.
Connecting an action.

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!

  1. 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.

  2. Get an NSColor for each state of isFav — red for a favorite and gray for others.

  3. Create an NSImage from the symbol name using the name of the image as its accessibility label.

  4. Create a SymbolConfiguration for the NSImage. This can take a palette of colors for multicolor symbols, but you only want one color, so set paletteColors to a single element array.

  5. Finally, set the button’s image using 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?

  1. Check to see if there’s a value in selectedMovie. If there isn’t, this method won’t do anything.
  2. Toggle isFav for the movie. This uses a Boolean method that swaps between false and true.
  3. 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.

Favorite movie
Favorite movie

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

  • NSTableViewDelegate can 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.