Chapters

Hide chapters

macOS Apprentice

Third Edition · macOS 26 · Swift 6 · Xcode 26

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

13. Powering Up Your Table
Written by Sarah Reichelt

In the previous chapter, you designed and built the interface for users to select a movie and read its details.

Now, you’ll move on to adding more features to the table itself. You’ll create a toolbar with a search field and you’ll make the table columns sortable.

You’ve already given users the ability to mark their favorite movies, but this data doesn’t persist between app launches. In order to store the favorites settings, you’ll learn how to save the data and reload it. This involves interacting with the Mac sandbox.

Adding a Search Field

With over 34,000 movies in the table, scrolling to find the one you want isn’t easy. So it’s time to add search. The appropriate place for a search field is in the window’s toolbar, and that’s where you’ll add it.

Open Xcode with your project from the previous chapter or use the starter project from the downloads for this chapter.

Open Main.storyboard to set up the toolbar. You might expect to add it to ViewController since that’s where you’ll use it, but a toolbar is part of a window, so you add it to the window.

Scroll the display so you can see the window with the Movies title. Press Shift-Command-L to open the Library and search for toolbar, then drag a Toolbar into the window:

The default toolbar.
The default toolbar.

By default, the toolbar has three items (apart from spacers) and you don’t want any of them. Double-click anywhere in the toolbar to open its editor:

The toolbar editor
The toolbar editor

One at a time, select Colors, Fonts and Print from Allowed Toolbar Items and press Delete. Next, open the library again and search for search. :]

Drag a Search Toolbar Item into the toolbar in Allowed Toolbar Items and then drag it from the toolbar down into the Default Toolbar Items box:

Adding the search item.
Adding the search item.

It ends up appearing twice, once in the allowed items box and once in the default items box.

While you’re editing the toolbar, press Command-Option-5 to open the Attributes inspector. Set Display to Icon Only so the search field doesn’t have an unnecessary label:

Configuring the toolbar display mode.
Configuring the toolbar display mode.

Click Done to close the toolbar editor.

This adds the user interface, but it won’t do anything until you write the code to detect any search input and filter the movies.

Processing Search Input

The search field is in the window, but you want its data in the view controller. You’ll use the window controller to detect changes and pass them on.

You could create a custom subclass of NSWindowController, but you only need to add one method so you’ll create an extension instead. You’ve used extensions on your own objects, but you can add them to any class or structure, even if you didn’t write it.

Start by defining a property to hold the entered search text. Open ViewController.swift and add this property:

var searchText = ""

You’ll add more code to process that soon, but this lets you continue without getting an error.

Right-click in the Project navigator and select New Empty File. Change its name to WindowController.swift.

Set the file contents to:

// 1
import AppKit

// 2
extension NSWindowController: @retroactive NSSearchFieldDelegate {
}

These lines:

  1. Import AppKit so you can use AppKit classes.
  2. Create an extension of NSWindowController that conforms to NSSearchFieldDelegate. The @retroactive marker is a fix that Xcode suggests to remove a warning that this may not work if future versions of NSWindowController conform to NSSearchFieldDelegate automatically.

This delegate handles notifications coming from the search field.

Inside the extension, start typing control and select controlTextDidChange(_ obj:) from the autocomplete suggestions:

controlTextDidChange autocomplete
controlTextDidChange autocomplete

Xcode marks the method as public because that’s what the protocol specifies. This access level allows you to access the method from any of your files.

Replace the code placeholder with:

// 1
guard
  // 2
  let searchField = obj.object as? NSSearchField,
  // 3
  let viewController = NSApp.keyWindow?.contentViewController
    as? ViewController
else {
  return
}

// 4
viewController.searchText = searchField.stringValue

Stepping through this, you:

  1. Make two guard checks, returning if either fails.
  2. First, check that the notification’s object is a search field. All edit fields use this method, so you can’t assume this is for the search.
  3. Next, confirm that this window controller has access to a ViewController. You do this by asking the application for its active window and checking if that window’s contentViewController is a ViewController.
  4. If these checks pass, set the ViewControllers searchText property.

There’s one last step to make this work. Open Main.storyboard and expand Search in the outline view so you can see Search Field. Select Search Field and open the Connections inspector using Command-Option-7.

Drag from the circle beside delegate to Window Controller in the outline view:

Setting the search field delegate.
Setting the search field delegate.

This connects the search field to the delegate method you added to NSWindowController.

Responding to a Search

You’ve made the connections. Next, you need to respond to the user’s search input.

Open ViewController.swift and find where you defined searchText. Replace that definition with:

var searchText = "" {
  didSet {
    print(searchText)
  }
}

This adds a property observer to detect changes.

Run the app and type in the search bar while watching the Xcode console:

Search text
Search text

The delegate method sets searchText, so now you can use that to filter the table data. To do this, you’ll make a copy of the movies array. The original stays complete and you adjust the copy to suit the search text. The table displays the copy. If you change the original array, you’ll lose the movies that are filtered from the list.

Add this property to ViewController:

var visibleMovies: [Movie] = []

And in viewDidLoad, after the last line, add:

visibleMovies = movies

Next, add this method:

func searchMovies() {
  // 1
  if searchText.isEmpty {
    visibleMovies = movies
  } else {
    // 2
    visibleMovies = movies.filter { movie in
      // 3
      movie.title.localizedCaseInsensitiveContains(searchText)
    }
  }
  // 4
  moviesTableView.reloadData()
}

How does this search?

  1. If there’s no search text, set visibleMovies to the complete movies array.
  2. If the user has typed in some search text, use filter to loop through each movie in movies.
  3. Check if the title contains searchText using a String method that ignores case.
  4. Reload the data displayed in the table to show the changes.

To use this method, go back to the searchText property observer and replace the print line with:

searchMovies()

This covers the data side of the search. To fix the display side, open TableData.swift.

Displaying Search Results

Here, you need to change every reference to movies to visibleMovies, but before you start typing, there’s an easier way. :]

In numberOfRows(in:), right-click on the word movies and select Edit All in Scope from the contextual menu:

Edit All in Scope
Edit All in Scope

This selects all the instances of movies in this file and makes the first one editable. Change it to visibleMovies and press Return.

Renaming movies
Renaming movies

As you type, you can see the change appear in the other instances. In this case, the scope is this extension, so nothing outside this file changes.

Run the app now and type something into the search field:

Searching the movies.
Searching the movies.

The app is a lot more usable now! But it can be even better.

Sorting the Table

The movies table shows three column headers, and Mac users expect to be able to sort the table by clicking these. You don’t want to disappoint anyone, so that’s what you’ll add next.

You make a table column sortable by assigning a sort descriptor to it. A sort descriptor is a way of describing how to sort a collection. It includes the sort property, the sort direction and the sort method.

Open TableData.swift and add this method:

func addSortDescriptors() {
  // 1
  let titleSortDesc = NSSortDescriptor(
    key: "title",
    ascending: true,
    // 2
    selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))
  // 3
  let yearSortDesc = NSSortDescriptor(key: "year", ascending: true)
  let ratingSortDesc = NSSortDescriptor(key: "rating", ascending: true)

  // set sort for each column
}

This creates a sort descriptor for each column:

  1. The title sort is the most complex because it’s case-insensitive. You initialize an NSSortDescriptor with a key — the property name. Set ascending to true so the initial sort is A to Z and not Z to A.
  2. You supply the sort method as a selector. This is how you convert a method into an argument. You initialize a selector using #selector and, in this case, you supply an NSString method that compares two strings, ignoring case.
  3. The year and rating descriptors are simpler. They use the default compare selector, so you don’t have to include it in the initializer.

Now you’ve created these, you can assign them to each column.

Replace // set sort for each column with:

// 1
let titleColumnID = NSUserInterfaceItemIdentifier("TitleColumn")
// 2
let titleColumnIndex = moviesTableView.column(withIdentifier: titleColumnID)
if titleColumnIndex > -1 {
  // 3
  moviesTableView.tableColumns[titleColumnIndex]
    // 4
    .sortDescriptorPrototype = titleSortDesc
}

// 5
let yearColumnID = NSUserInterfaceItemIdentifier("YearColumn")
let yearColumnIndex = moviesTableView.column(withIdentifier: yearColumnID)
if yearColumnIndex > -1 {
  moviesTableView.tableColumns[yearColumnIndex]
    .sortDescriptorPrototype = yearSortDesc
}

let ratingColumnID = NSUserInterfaceItemIdentifier("RatingColumn")
let ratingColumnIndex = moviesTableView.column(withIdentifier: ratingColumnID)
if ratingColumnIndex > -1 {
  moviesTableView.tableColumns[ratingColumnIndex]
    .sortDescriptorPrototype = ratingSortDesc
}

This looks like a lot of code, but it’s the same sequence repeated for each column:

  1. Create an NSUserInterfaceItemIdentifier for the Title column, using the identifier you assigned in the storyboard.
  2. Query moviesTableView for a column with this identifier. It returns an array index or -1 if there’s no matching column.
  3. If the index is valid, use it to get the matching column from moviesTableView.tableColumns.
  4. Set that column’s sortDescriptorPrototype to the matching sort descriptor.
  5. Repeat for the process for the Year column and the Rating column.

You have to do it this way because the user may have dragged columns around. You can’t assume that the first column is the Title column. This is long-winded but it makes sure you assign the sort descriptors correctly, regardless of the user settings.

To call this method, open ViewController.swift and add this line at the end of viewDidLoad:

addSortDescriptors()

Run the app and click the table headers:

Sort headers
Sort headers

The selected column header is slightly bolded and an arrow points up or down to indicate the direction of the sort. So far, no actually sorting takes place, but that’s next on your to do list. :]

Applying the Sort

The NSTableViewDataSource receives a message when the user changes the sort. Open TableData.swift and make space at the end of the extension to add a new method.

Type sort and accept the only autocomplete suggestion:

sort autocomplete
sort autocomplete

The dataSource calls this method whenever the user clicks a header to change the sort, but it’s your responsibility to actually sort the data.

The nice thing is that with a bit of data type swapping, you can use the table’s sort descriptors to do this for you.

Replace the code placeholder with:

// 1
if let sortedMovies = (visibleMovies as NSArray)
  // 2
  .sortedArray(using: moviesTableView.sortDescriptors) as? [Movie] {
  // 3
  visibleMovies = sortedMovies
  // 4
  moviesTableView.reloadData()
}

How does this work?

  1. In order to sort using the table’s sort descriptors, convert the Swift Array into an Objective-C NSArray. Objective-C was Apple’s language before Swift and a lot of the older frameworks still use it.

  2. Use an NSArray method to sort using the sort descriptors you already assigned to each column. The table manages these and they change whenever the user clicks a header. Once that’s finished, try to convert the NSArray back into a Swift array of Movie objects.

  3. Converting to a Swift array can fail because an NSArray can contain any types of object where a Swift array only contains a single type. If the conversion succeeds, set visibleMovies to the sorted array.

  4. Reload the table to show the sorted data.

Don’t run the app yet or it’ll crash. There’s a twist to using an Objective-C method.

Adding Attributes

Whenever you want to be able to refer to a Swift class or property from an Objective-C method, you give it a special attribute. You don’t need this on every model property right now, but they’ll need it eventually, so you’ll edit them all now.

Open Data & Models ▸ Movie.swift. and replace the class definition line with:

@objc class Movie: NSObject, Codable {

The @objc attribute tells the Objective-C runtime that it can access this class. For this to work, the class has to be a subclass of NSObject, which is the ultimate parent of all Objective-C classes.

Next, you add this attribute to all the properties. There’s a fast way to do this using multi-cursor editing.

Place the cursor before let id. Hold down Option and click and drag down to get a cursor at the start of each property line. Now, type @objc followed by a space, and watch it appear eight times as you type it once:

Multi-cursor editing
Multi-cursor editing

Click anywhere else to revert to a single cursor. This style of editing can take a bit of getting used to, but it’s a convenient tool to have at your disposal.

You have an error now because you applied the @objc attribute to principals before adding it to the Principal class, so open Principal.swift.

Change the class declaration to:

@objc class Principal: NSObject, Codable {

And insert @objc before each of the four properties like you did for Movie.

Now you can run the app and sort the columns:

Sorting table columns.
Sorting table columns.

But there’s a bug. What happens if you sort by year and then do a search? The search results are still sorted by title!

Sorting the Search Results

Open ViewController.swift and find searchMovies. Before reloading the table, insert this:

if let sortedMovies = (visibleMovies as NSArray)
  .sortedArray(using: moviesTableView.sortDescriptors) as? [Movie] {
  visibleMovies = sortedMovies
}

This is the same code as you used in the dataSource and does the same NSArray shuffle and Objective-C sorting for visibleMovies.

Run the app again, sort by year and enter some search text:

Sorted search results.
Sorted search results.

Hurray! It all works.

Saving the Table Setup

Run the app and change the way you display the table. Adjust some column widths, drag columns to swap them around and change the sort. Resize and move the window.

Next, quit and app and restart it. The window remembers where you left it, but your table has forgotten all its settings.

In an earlier chapter, you set an Autosave name for the window. This gave the app a key that it uses to save and restore your window size and location. You’ll do the same thing for the table.

Open Main.storyboard and select the Movies Table View in the View Controller Scene. Shift-right-click the table to get the subview menu, so you can be sure you have the correct element selected.

Open the Attributes inspector using Command-Option-5 and set Autosave to MoviesTable. Check Column Information to make sure this saves the column widths and positions too:

Setting table autosave.
Setting table autosave.

Autosave names can be any string so long as each is unique in your project.

Run the app again, set up your columns, then quit and restart. This time, your columns are the way you left them:

Column setup restored.
Column setup restored.

You’ve reached the end of this long sorting section. You know how to set up a table for sorting, you know how to react to changes in the sort and you know how to save and restore the user’s settings. Great work!

In the previous chapter, you allowed users to mark movies as their favorites. But each time the app restarted, these marks disappeared. You’re saving your users’ app settings, so now it’s time to work out how to save their data changes too.

Saving Your Data

Every time the app starts, it reads in the list of movies from movies.json. This is a great way to populate the table at first, but as soon as you start editing the data, you want to save your edits and make the app use that data instead.

You’ll create a new data structure to handle all data reading and writing.

Select Data & Models in the Project navigator and then use your preferred method to open the New File from Template dialog. Choose Swift File and name it DataStore.swift.

This object has three tasks: reading the original data, saving edited data and reading edited data, if it’s available. Right now, you have an extension on Movie that reads the original, but you’ll move that.

Start by adding this to your new file:

struct DataStore {
}

This sets up a structure called DataStore.

Next, open Movie.swift and find readBundleData(). Select the entire method and press Command-X to cut it from this file.

Swap back to DataStore.swift and press Command-V to paste the method in the structure. Delete the static keyword. This is now an instance method that you call on a DataStore object, not on the structure itself.

To tidy things up, go to Movie.swift and delete the now empty extension.

With these changes in place, the app won’t display anything, so you’ll fix that before you add any other methods.

Open ViewController.swift and add a new property:

var dataStore = DataStore()

This defines and initializes dataStore in a single line.

Scroll down to viewDidLoad() that now shows an error because Movie no longer has readBundleData().

Change the error line to:

movies = dataStore.readBundleData()

This sets everything back to where it was before, but doesn’t add anything new yet.

The Mac Sandbox

When you create an app project, it doesn’t get access to everything on your Mac. It has its own sandbox where it’s allowed to read and write files. Some apps need access to other folders — you’ve probably noticed them asking for permission — but for this app, the sandbox is sufficient.

But where is the sandbox?

Switch to Finder and open the Go menu. Hold down Option and when Library appears, choose it.

Find the folder called Containers and open it. It’s full of folders, some of which have strange names and some of which even share names! They don’t actually share names, but Finder shows you a friendly name for each one.

Scroll to find the folder called MovieTables. Select it and press Command-I to Get Info about it:

Container folder info
Container folder info

This shows data about the folder and tells you that its real name is com.yourcompany.MovieTables. (If you changed the bundle identifier from the starter project, you’ll see that in the name.)

Inside this folder is a Data folder, and inside that is where things get interesting:

Container folder contents
Container folder contents

Some of the folders have a small black arrow in the bottom left of the icon. These are aliases to standard folders on your Mac. If your app tried to access them, the user would see a permission prompt. The others are folders that belong to this app only. You can access them freely, but no other apps can. This allows you to save your data file to your app’s own Documents folder, knowing that no other app can overwrite it.

The Data ▸ Library ▸ Preferences folder has a file called com.yourcompany.MovieTables.plist that stores the window and table column settings.

Note: You can do a complete reset of your app by quitting it and then deleting its container folder. It’s always a good idea to do this before shipping an app, so you can test a fresh install.

But enough with the theory, it’s time to save some data.

Saving Movie Data

Open DataStore.swift and add this computed property:

var savedDataURL: URL {
  URL.documentsDirectory.appending(component: "movies.json")
}

URL has a property to refer to the Documents directory or folder. This is the Documents folder inside the sandbox, not the main one in your user folder. Once you have a reference to the documents folder, you can append a file name.

Now, to convert the data and save it, add this method:

func saveData(movies: [Movie]) {
  // 1
  do {
    // 2
    let jsonData = try JSONEncoder().encode(movies)
    // 3
    try jsonData.write(to: savedDataURL)
  } catch {
    // 4
    print(error)
  }
}

Here’s what’s going on:

  1. Since converting to JSON and writing to a file can both fail, use a do block to catch any errors.
  2. Try to use a JSONEncoder to convert movies to Data. This is the reverse of the JSONDecoder method you used when reading the data.
  3. Then, try to write this data to the file URL.
  4. If any part of this fails, drop into the catch block and print an error report.

This is ready for use now, so open ViewController.swift and find favButtonClicked(_:). After the showSelectedMovie line, add:

dataStore.saveData(movies: movies)

This means that every time you change an isFav property, dataStore saves your new data file. Because Movie is a class, it’s passed around by reference. This means that changing a property of selectedMovie flows through to the movies array and the visibleMovies array.

Time to test this. Run the app, select the first movie in the list and set it as a favorite. Go back to Finder and your Container folder. Now, there’s a movies.json file in Documents:

Saved data file in container
Saved data file in container

Don’t try to open the file in Xcode — it’ll choke and become unresponsive. To test whether the app saved your new data, you’ll change the code to read data from this saved file.

Reading Saved Data

At the moment, you’re calling readBundleData() in DataStore to read the default data file. You still want to use this method, but only if there’s no stored data file.

Open Data & Models ▸ DataStore.swift and add this new method:

// 1
func readStoredData() -> [Movie] {
  // 2
  do {
    let jsonData = try Data(contentsOf: savedDataURL)
    let movies = try JSONDecoder().decode([Movie].self, from: jsonData)
    let sortedMovies = movies.sorted { movieA, movieB in
      movieA.title < movieB.title
    }
    return sortedMovies
  } catch {
    // 3
    return readBundleData()
  }
}

A lot of this is familiar:

  1. The method returns an array of Movie objects.
  2. Use do to try to read and process the saved data file. This is the same code as in readBundleData().
  3. Here’s the big difference. If this fails, as it will if the file doesn’t exist, use the catch block to fall back to reading the default data file.

With this in place, open ViewController.swift and in viewDidLoad(), replace movies = dataStore.readBundleData() with:

movies = dataStore.readStoredData()

Run the app and select the first movie to see your previously selected heart:

Displaying stored data.
Displaying stored data.

To confirm the the fallback works, quit the app and delete movies.json from the app’s container. When you run the app again, the movies data appears, but not your heart.

You’re storing and reading the edited data and you have a working fallback solution.

Working with Threads

You may have noticed that when you click the heart to toggle a movie’s favorite status, there’s a slight delay. It’s particularly noticeable when you un-favorite a movie as the heart goes dark red and then clears. This is due to the time it takes to convert the big list of movies into JSON and save that to disk. While your app is busy with that, it hasn’t time to update the interface.

But aren’t modern computers super-powerful? Why can’t my Mac do more than one thing at a time? Well, it can, but by default, it doesn’t.

When you run your app, it creates a series of threads. Each of these threads handles a different task, but everything that updates the display must happen on the main thread. Processing data and saving a file can happen on a background thread where it doesn’t interfere with any interactions or display updates.

In DataStore.swift, replace the contents of saveData(movies:) with:

// 1
DispatchQueue.global().async { [savedDataURL] in
  do {
    let jsonData = try JSONEncoder().encode(movies)
    try jsonData.write(to: savedDataURL)
  } catch {
    print(error)
  }
// 2
}

There are only two new lines here, but the first one does a lot of work:

  1. A DispatchQueue manages a series of tasks. When you add a task to this queue, it processes it when the queue has finished any previous tasks. The global dispatch queue is a system queue that you can always access. The async method asks the queue to perform this task asynchronously and not to pause the rest of the app by waiting until it’s finished. Putting savedDataURL in square brackets captures it. This stores a value for savedDataURL, which the asynchronous block can use even if some other code changes it while the block is still working. Without this, you’ll get an obscure warning about “Main actor-isolated property” if you’re using Swift 6 or later.
  2. The other new line closes the curly brace, so the entire do block happens asynchronously.

Run the app again and test clicking the heart. This time, the display updates immediately:

Saving asynchronously.
Saving asynchronously.

Now you’re saving and loading data, while still providing a responsive experience for the user.

Key Points

  • In an AppKit app, a toolbar is part of the window.
  • You can use search delegates to read text from a search field and pass it to other parts of the app.
  • Tables need sort descriptors to make columns sortable. Sorting based on sort descriptors uses NSArray methods.
  • Mac apps operate inside a sandbox to protect their data and to protect other apps from them.
  • Background threads can be used for tasks that don’t change the display. This keeps your app’s interface responsive.

Where to Go From Here?

You’ve done a lot of work in the main window, but so far, you haven’t looked at the main menu bar. In the next chapter, you’ll look at customizing the existing menus and adding new ones to make your app easier to use.

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.