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:
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:
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:
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:
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:
- Import AppKit so you can use AppKit classes.
- Create an extension of
NSWindowControllerthat conforms toNSSearchFieldDelegate. The@retroactivemarker is a fix that Xcode suggests to remove a warning that this may not work if future versions of NSWindowController conform toNSSearchFieldDelegateautomatically.
This delegate handles notifications coming from the search field.
Inside the extension, start typing control and select controlTextDidChange(_ obj:) from the autocomplete suggestions:
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:
- Make two
guardchecks, returning if either fails. - First, check that the notification’s
objectis a search field. All edit fields use this method, so you can’t assume this is for the search. - 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’scontentViewControlleris aViewController. - If these checks pass, set the
ViewControllerssearchTextproperty.
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:
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:
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?
- If there’s no search text, set
visibleMoviesto the completemoviesarray. - If the user has typed in some search text, use
filterto loop through eachmovieinmovies. - Check if the
titlecontainssearchTextusing aStringmethod that ignores case. - 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:
This selects all the instances of movies in this file and makes the first one editable. Change it to visibleMovies and press Return.
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:
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:
- The title sort is the most complex because it’s case-insensitive. You initialize an
NSSortDescriptorwith a key — the property name. Set ascending totrueso the initial sort is A to Z and not Z to A. - You supply the sort method as a selector. This is how you convert a method into an argument. You initialize a selector using
#selectorand, in this case, you supply anNSStringmethod that compares two strings, ignoring case. - The
yearandratingdescriptors are simpler. They use the defaultcompareselector, 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:
- Create an
NSUserInterfaceItemIdentifierfor the Title column, using the identifier you assigned in the storyboard. - Query
moviesTableViewfor a column with this identifier. It returns an array index or-1if there’s no matching column. - If the index is valid, use it to get the matching column from
moviesTableView.tableColumns. - Set that column’s
sortDescriptorPrototypeto the matching sort descriptor. - 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:
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:
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?
-
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. -
Use an
NSArraymethod 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 theNSArrayback into a Swift array ofMovieobjects. -
Converting to a Swift array can fail because an
NSArraycan contain any types of object where a Swift array only contains a single type. If the conversion succeeds, setvisibleMoviesto the sorted array. -
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:
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:
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:
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:
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:
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:
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:
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:
- Since converting to JSON and writing to a file can both fail, use a
doblock to catch any errors. - Try to use a
JSONEncoderto convertmoviestoData. This is the reverse of theJSONDecodermethod you used when reading the data. - Then, try to write this data to the file URL.
- If any part of this fails, drop into the
catchblock 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:
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:
- The method returns an array of
Movieobjects. - Use
doto try to read and process the saved data file. This is the same code as inreadBundleData(). - Here’s the big difference. If this fails, as it will if the file doesn’t exist, use the
catchblock 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:
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:
- A
DispatchQueuemanages a series of tasks. When you add a task to this queue, it processes it when the queue has finished any previous tasks. Theglobaldispatch queue is a system queue that you can always access. Theasyncmethod asks the queue to perform this task asynchronously and not to pause the rest of the app by waiting until it’s finished. PuttingsavedDataURLin square brackets captures it. This stores a value forsavedDataURL, 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. - The other new line closes the curly brace, so the entire
doblock happens asynchronously.
Run the app again and test clicking the heart. This time, the display updates immediately:
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.