macOS programming · · 35 min read

macOS Programming: Working with Table Views

macOS Programming: Working with Table Views

Welcome to a new macOS programming tutorial which I believe you will find quite interesting! Today we are going to talk about one of the most common Cocoa controls found in macOS applications: Table views.

A table view in macOS can present collections of data spread in rows and columns. Unlike iOS where the concept of a column practically does not exist, table views on macOS can have as many columns as you want. Also, although there are a few similarities on managing table views between iOS and macOS, you’ll find out there are more concepts to deal with here, a more complex structure, but more possibilities as well. Even though working with table views on macOS might look hard at first, at the end it tends to be one of the most straightforward topics one can deal with.

A single tutorial cannot be enough to cover all aspects of table views, however by leaving today you’ll have all the necessary knowledge that will enable you to integrate table views in your projects. And as always, you are encouraged to keep digging and experimenting more and more beyond what you’ll read here.

Without any further delay, let’s have a quick look at what we are going to work with today, and then straight ahead on getting our hands dirty.

Editor’s note: If you’re new to macOS programming, please check out the introductory tutorial here.

About The Demo App

As it usually happens, we need a demo application to work on and we won’t start one from scratch. Instead, there is a starter project to download and open in Xcode. The demo app today is a list of fake users along with payment and purchased items information. More specifically, the table view that we will integrate into the project will display:

  • A record ID value.
  • An avatar image.
  • A username.
  • Credit card number.
  • Credit card type.
  • Paid amount.
  • Kinds of purchased items.

Here’s a sample of what we’ll manage to create in the following parts:

macOS-programming-tableview-sample

As said, data is fake and generated at Mockaroo, a website to create mock data of any kind for testing apps. Robot avatars were delivered by Robohash.org. Special thanks to both for making possible the generation of data used in this tutorial.

Apart from learning how to create columns and cells to populate data as shown above, we will also see how to use custom views instead of the default cell views. By doing so, we’ll manage to have two display modes in our app: Plain and detail. When we’ll make it possible to switch to detail display mode, data on the table view will be presented like so:

macOS-programming-custom-tableview

In addition to all the above, we’ll also see:

  • How to handle single and double click on rows.
  • How to present and handle row actions, the buttons that appear when a row is swiped to left or right.
  • How to do various tasks in code, such as creating or removing columns on the fly, how to disallow row selection, and more.
  • How to sort displayed data when clicking on a column.

The demo project is based on the MVVM design pattern. PurchasesModel.swift file contains the model of the app, which is parted by a few simple structs:

struct Purchases: Codable {
    var id: Int?
    var userInfo: UserInfo?
    var paymentInfo: PaymentInfo?
}


struct UserInfo: Codable {
    var id: Int?
    var username: String?
}


struct PaymentInfo: Codable {
    var creditCard: String?
    var creditCardType: String?
    var amount: String?
    var purchaseTypes: [String]?
}

Sample data can be found on the MOCK_DATA.json file. It is JSON data that contains 20 sample records. That JSON encoded data is being decoded to objects in the ViewModel class (see loadDummyData() method) which in turn are stored to the purchases array, a collection of Purchases objects. purchases property in the ViewModel class is going to be the datasource for the table view.

In addition to the sample data, avatars used for the fake users can be also found in the starter project under the Avatars group.

ViewModel class in the ViewModel.swift file stands between the ViewController class that represents the View part of the MVVM, and the model. Whatever we are going to need from the ViewModel class is already implemented, so we will be using it as is in the ViewController. This is the place where we’ll write any code presented in the following parts, but we’ll also spend time in the Main.storyboard file too.

As I mentioned already above, one of the topics we’ll discuss today regards how to use custom views instead of cells for displaying data to table views. The custom view that we’ll use for this purpose in this demo app is already implemented, and it can be found in the PurchasesDetailView.swift and PurchasesDetailView.xib pair of files.

Finally, a few non-important stuff have been implemented in the ViewController class. We’ll keep adding code there until the end of this tutorial.

Take your time to walk yourself through the starter project, and keep reading here when you’re done. We’ll start by seeing a few general facts about table views.

The Anatomy Of A Table View At A Glance

Before we start doing some actual work, let’s have a quick look at the anatomy of a table view. Even though a table view on macOS allows for great flexibility in presenting collections of data and in using any custom UI, its structure is a bit complex and surely not what a new-comer would expect to meet. So, let’s make things simple at first.

The following figure is based on the table view that we will implement in this demo app:

mt5_3_tableview_marked

And this is its structure as presented in the Document Outline:

mt5_4_tableview_sample_doc_outline

The “base” view of a table view is a scroll view, which has as a subview a clip view. The purpose of such an object is to “clip a document to a scroll view’s frame” (according to Apple’s official documentation). Table view is a subview of the clip view.

A table view can have one or more columns (also called header views), which can have optionally a title, they can be visible or not, resizable and capable of sorting their contents. Also, in case of multiple columns, reordering them can be an option too.

A column usually contains at least one table cell view, a subclass of the NSView class. Even though cell views can be customized to contain any UI controls required, a text field is what by default cell views contain when columns are added to a table view in Interface Builder. There are other predefined cell views which can be found in the Objects Library, with the most common one being a cell view with an image view and a text field.

Table cell views provide default outlet properties for the text field and the image view. To add more controls to the cell, or extra text fields and image views, a subclass of the NSTableCellView class must be created containing the outlet (IBOutlet) properties that should be connected to those controls. This is something that we’ll do later in this tutorial, so more details will come soon.

For full flexibility, custom views (NSView views) can be used in place of cells. More about that later too, as that’s another topic coming next.

Finally, data is displayed in rows as you can see in the first picture above. Accessing rows as objects (NSTableRowView objects) is possible through code.

So, to summarize, a table view structure has the following hierarchy:

Scroll View > Clip View > Table View > Column(s) > Table Cell View(s) > UI Control(s)

For further reading and deeper understanding, I recommend to see how this Apple doc describes a table view.

Note: There are two different types of table views: Cell-based and view-based. We will focus on the view-based cells only, as this is the recommended way by Apple to use table views nowadays. However, if you are interested to learn more about that, you can search online for cell-based vs view-based table views.

Adding A Table View

The first thing we will do in the starter project is to add a table view and set it up. Open the Main.storyboard file and go to the View Controller scene where you will find only a button and a label. To add a new table view:

  1. Open the Objects Library and search for the “tableview” term.
  2. From the shown results, drag and drop a Table View object to the view controller’s interface.

To properly place it in the view controller’s view, make sure that the table view (the container scroll view actually) is selected, and then set its constraints: Top, Leading, Trailing, Bottom: 20.

At the end, you should have something like that:

mt5_5_new_tableview

To see the hierarchy of objects that make up a table view in real for first time, open the Document Outline and expand all the inner items starting from the “Bordered Scroll View – Table View” item.

mt5_6_doc_outline_real

Note: Keep in mind that since the actual table view object is nested in other UI controls, it is not the one being selected when you click on it in the view controller scene. That’s because clicking is not happening on the table view, but on its scroll view. To access the table view, either make a couple of subsequent clicks, or use the Document Outline pane to expand and reach to the table view object.

As you can see, a brand new table view has two columns by default. Each column has a table cell view with a text field already existing in it. Both text fields in the two columns are automatically connected to the default outlet property provided by the table cell view. Open the Connections Inspector to see that:

mt5_7_default_outlet_connection

To change the number of the available columns, as well as to configure numerous other options, select the table view object and then open the Attributes Inspector.

mt5_8_tableview_attrs_inspector

The first thing that can be changed here is the content mode of the table view, which actually specifies whether its content should be cell or view based. We will keep the default “View Based” option. Right next, there is a text field with a stepper on its right, where the number of columns can be modified. Default that value is 2, but change it to see what happens if you want.

Note: If you try to add more columns but they are not visible in the table view, then select the second column and reduce its width from the Sizes Inspector.

Next, depending on the requirements of the app you are building, you can specify whether you want to have column headers visible or not, if you want to allow reordering of the columns, even if you want to allow resizing. Additional options about resizing can be set, such as resizing the last column only (the by default option), first column only, all, and more.

In the following fields you can set other preferences regarding the appearance of the table view. One field that you might find interesting is the Alternating Rows check box. If you enable that, subsequent rows on the table view will have different colors.

mt5_9_alternating_rows

Using the Horizontal Grid and Vertical Grid popups you can also display horizontal and vertical grid lines to the table view respectively, making clear visual separation of the cells. Lines can be solid or dashed, but the default option is “None”, meaning no grid lines at all. Line colors can be changed in the popups that follow, where you can either leave the default colors, or set the custom ones you want.

mt5_10_grid_lines

After that, you can make selection-related configuration. For example, you might want to allow multiple rows to be selected, or an entire column.

The above consist of the most important and common options that you will probably need to configure when working with table views on macOS. Feel free to go deeper and experiment with more settings that they were not presented above. There is, however, one more thing that I would like to highlight here, and it has to do with the background color of the table view.

In case you want to set a custom background color to the table view, you will do so using the Background popup in the Attributes Inspector. Keep always in mind though that table view is not a top-level object; it’s a subview of the clip view, and the clip view is a subview of the scroll view. So, always make sure that both the scroll view and the clip view have no solid background colors. Otherwise the color you set as a background for the table view will not be visible.

Setting Up The Table View

With all the above being said, make the following settings to the table view of our demo app through the Attributes Inspector:

  • Change the number of columns to 3.
  • Uncheck the Reordering and Resizing check boxes.
  • Allow dashed horizontal grid lines.
  • Enable Multiple selection check box.

Now, select the first table column in the Document Outline, and In the Attributes Inspector set the text “ID” as its title.

mt5_11_column_title

With the column still selected, open the Identity Inspector, and set the “idColumn” as the value to the Identifier field.

mt5_12_column_identity

Lastly, set its width value to 48 in the Size Inspector.

mt5_13_column_size

Repeat the above steps for the other two columns. For the second column, set the following values:

  • Column title: “User Info” (without the quotes)
  • Identity: “userInfoColumn”
  • Size: 200

Regarding the third column:

  • Column title: “Payment Info” (without the quotes)
  • Identity: “paymentInfoColumn”
  • Size: 600

As you see, part of the configuration we make is to set the Identifier values for each column. These identifiers are extremely important and necessary as they are used to distinguish columns programmatically, and you will see how they are being used in the next part of the post.

As you’ve seen so far, a table cell view has a text field as a subview by default. That’s suitable to present values in the “ID” column, but it’s not good for the second column where we’ll be presenting avatars and usernames (“User Info” column). In that case we need to replace the default cell view with another one, also built-in table cell view that will contain a text field and an image view. Here’s the series of step required to do that:

  • Select the cell view in the second column, and hit the Delete button on your keyboard to delete it.
  • Open the Objects Library and search for the “tablecellview” term.
  • Select the Image & Text Table Cell View object and drag and drop it under the second column. That should be the only cell view for that column.
  • Optionally, open the Connections Inspector to see that the text field and the image view that this cell contains are already connected to the default outlet properties.

With the new table cell view being in place, select it and then go to the Identifier Inspector to set an identifier value as we did right before with columns. Set “userInfoCell” as the Identifier value.

mt5_14_user_info_cell_identifier

After that, do the same for the cell view for the first column. Set the “idCell” text as the Identifier value.

mt5_15_id_cell_identifier

Regarding the table cell view of the third column, set the “paymentInfoCell” text as its Identifier.

Lastly, we must connect our table view to an IBOutlet property called tableView which has been declared already in the ViewController class. Select the View Controller object in the Document Outline, and open the Connections Inspector. Drag the tableView outlet straight to the table view.

mt5_16_tableview_outlet

Presenting Data

Let’s switch to the ViewController.swift file now and let’s start writing some code. In this part we are going to see how default table cell views can be used for displaying data to the table view. This approach consists of the easiest, and most probably the most common way to populate data to a table view.

The first thing we need to do is to set the ViewController class as the delegate and datasource of the table view. Go to the viewDidLoad() method and add the next two lines:

override func viewDidLoad() {
    super.viewDidLoad()

    tableView.delegate = self
    tableView.dataSource = self
}

Xcode will show a couple of error messages, but don’t bother about that. We’ll fix everything in a minute.

The datasource for our table view is going to be the purchases collection in the ViewModel class. purchases is a collection of Purchases objects, where each object contains fake data that contain usernames, credit card and payment details regarding fake purchases.

We will use the purchases collection for the first time by implementing a datasource method that returns the number of rows in the table view. We will do that to an extension of the ViewController class. Go after the class closing and add the following:

extension ViewController: NSTableViewDataSource {

}

Inside the extension body let’s define the datasource method shown next:

extension ViewController: NSTableViewDataSource {
    func numberOfRows(in tableView: NSTableView) -> Int {
        return viewModel.purchases.count
    }    
}

In the numberOfRows(in:) datasource method we return the number of items in the purchases collection. Now, let’s create another extension where the ViewController class will conform to the NSTableViewDelegate:

extension ViewController: NSTableViewDelegate {

}

By adding the second extension no more errors are appearing in Xcode.

The delegate method that you will be using the most when you’ll be working with table views is the one defined right next:

extension ViewController: NSTableViewDelegate {
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {

    }
}

This method is responsible for loading and managing view-based cells, and it’s also the place where we will add the necessary code to present data to the cell views in our table view. We will start by assigning a Purchases object into a local variable for easy access, and also by returning nil as the default value of the method.

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
    let currentPurchase = viewModel.purchases[row]

    return nil
}

Next, we are going to distinguish columns by using their identifier values we set earlier in the storyboard file. Here’s an important tip: An identifier value in code is not a String value, but a NSUserInterfaceItemIdentifier object. Using such an object to check the identifier of a column is easy as it’s shown next:

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
    let currentPurchase = viewModel.purchases[row]

    if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "idColumn") {

    } else if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "userInfoColumn") {

    } else {

    }

    return nil
}

tableColumn parameter value represents a table view column object. Its identifier property is what we compare to the NSUserInterfaceItemIdentifier object and we eventually manage to distinguish each column. The else case above represents the third column, and we’ll deal with it in the next part of the post.

Our next move is to load the table cell view for each column and assign the proper data. We’ll handle the case of the cell view in the first column initially. Let’s see the code:

if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "idColumn") {

    let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "idCell")
    guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { return nil }
    cellView.textField?.integerValue = currentPurchase.id ?? 0
    return cellView

} else if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "userInfoColumn") {

} else {

}

The key player in these new four lines is the makeView(withIdentifier:owner:) method, as it’s the one that creates a view based on the cell view that has an identifier value matching to the one we provide. In the above case, “idCell” is the identifier of the cell view in the first column. Note that the returned value by that method is a NSView? object, so casting to NSTableCellView is necessary, as well as using a guard statement (or if let if you prefer) to make sure that there’s an actual cell view returned.

The rest is straightforward, as we just assign the ID value of the current Purchases object to the default text field of the cell view, which we eventually return. The cellView object that is being returned will either have a value, or it will be nil if makeView(withIdentifier:owner:) returns nil for some reason (for example, we specified a wrong identifier).

Time to take care of the User Info column contents. We will follow the exact same steps as shown above, but now we will refer to the cell using the “userInfoCell” identifier. This time an image view exists in addition to the text field in the table cell view.

let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "userInfoCell")
guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { return nil }
cellView.textField?.stringValue = currentPurchase.userInfo?.username ?? ""

if let avatarData = viewModel.getAvatarData(forUserWithID: currentPurchase.userInfo?.id) {
    cellView.imageView?.image = NSImage(data: avatarData)
}

return cellView

In the text field we assign the username of the fake person, and in the image view the avatar. No image is set as an avatar if getAvatarData(forUserWithID:) method returns nil instead of an actual Data object. You can find getAvatarData(forUserWithID:) implemented in the ViewModel.swift file.

Right next you are given with the implementation of the tableView(_:viewFor:row:) delegate method as it’s been formed so far. We’ll keep adding code in this method in the upcoming parts too:

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
    let currentPurchase = viewModel.purchases[row]

    if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "idColumn") {

        let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "idCell")
        guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { return nil }
        cellView.textField?.integerValue = currentPurchase.id ?? 0
        return cellView

    } else if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "userInfoColumn") {

        let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "userInfoCell")
        guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { return nil }
        cellView.textField?.stringValue = currentPurchase.userInfo?.username ?? ""

        if let avatarData = viewModel.getAvatarData(forUserWithID: currentPurchase.userInfo?.id) {
            cellView.imageView?.image = NSImage(data: avatarData)
        }

        return cellView


    } else {

    }

    return nil
}

We are just a step away from running the app for first time and seeing the fake data displayed on the table view. The last action we have to take is to reload the table view data, and we will do so in the viewWillAppear() method:

override func viewWillAppear() {
    super.viewWillAppear()
    tableView.reloadData()
}

Run the app now, you should see this:

mt5_17_tableview_first_run

Changing Row Height

If you’ve been programming in iOS, then most probably you can guess how row height can be altered. By default, each row has a height of 17px. To change that you need to implement the following delegate method and provide a new height as a CGFloat value:

extension ViewController: NSTableViewDelegate {
    ...


    func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
        return 21.0
    }
}

Most times you will want to make respective changes in the storyboard file too, so UI design output matches to the runtime visual results. To do that, select each table cell view under each column, and change their heights through the Size Inspector to a value similar to the one you set in code. In case you set a high height value, then it’ll be necessary to resize each UI control accordingly so it fits to that height. This is not important here, as the new height we set is close to the original one.

mt5_18_resize_cell_views

Subclassing Table Cell View

Displaying data as shown in the previous part is not always the case; chances are that you will need more controls or customized UI on a table cell view. Default controls provided by cells might not be enough, neither the respective outlet properties. In such cases there is one solution, to create your custom table cell view class with all outlet properties and action methods required to support the custom UI of a cell view.

This is what exactly we are going to see here by subclassing the NSTableCellView class. To start, go back to the Main.storyboard file and in the third column of the table view. The following data is going to be presented in it:

  • Credit card number
  • Credit card type
  • Purchases amount
  • List of purchases

For the first three kind of data we are going to use labels (non-editable text fields), but for the purchases list we will use a popup button. Let’s start building the UI on the cell view of the table view, where we already have a text field (the default one). From the Objects Library, add two (additional) text fields and one popup button to the cell:

mt5_19_doc_outline_additional_fields

Starting from the first text field and going to the popup button, set their frames in the Size Inspector as described:

  • First text field: 2, 7, 192, 17
  • Second text field (label): 202, 7, 130, 17
  • Third text field (label): 340, 7, 122, 17
  • Popup button: 470, 2, 115, 21

You should now have this layout:

mt5_20_third_column_ui

Now we will leave Interface Builder for a while as it’s time to subclass the NSTableCellView class. In the starter project there is a file named PaymentInfoCellView.swift file, currently being empty. Open it, and start by defining a new class:

class PaymentInfoCellView: NSTableCellView {

}

The one and only action we have to take here is to declare IBOutlet properties that will be connected to the controls we just added above:

class PaymentInfoCellView: NSTableCellView {

    @IBOutlet weak var creditCardTypeLabel: NSTextField?
    @IBOutlet weak var amountLabel: NSTextField?
    @IBOutlet weak var purchasesPopup: NSPopUpButton?

}

Notice that we don’t declare an IBOutlet property for the text field that will present the credit card number data. We could have done it, but it’s not necessary as NSTableCellView already provides an outlet property for a text field. Why not use it?

A class such the above one is also the place where any IBAction methods should be implemented for reacting to user actions. We don’t need to do that here though, so the above is all we have. Keep in mind that in case you implement IBAction methods and you need to send data to ViewController or any other class, then using delegation pattern or notifications for doing that would be a good option.

Back to the Main.storyboard file again. Let’s spot and select the cell view of the third column. Then, show the Identity Inspector, and set the PaymentInfoCellView as the custom class of the cell:

mt5_21_set_custom_cellview_class

Keep the cell view selected, and then open the Connections Inspector. You will see all IBOutlet properties we declared in the PaymentInfoCellView class. Make the connections according to this:

  • textField outlet to the first text field.
  • creditCardTypeLabel outlet to the second text field.
  • amountLabel outlet to the third text field.
  • purchasesPopup outlet to the popup button.

Once you finish making the connections, go to the ViewController.swift file, in the tableView(_:viewFor:row:) delegate method. We have the if statement to distinguish columns, and the else case is still missing implementation. Now we are in a position to add it, so start by making the cell view:

let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "paymentInfoCell")
guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? PaymentInfoCellView else { return nil }

Notice that we are casting the view created by the makeView(withIdentifier:owner:) method to a PaymentInfoCellView type this time, instead of NSTableCellView. Through the cellView object now we can access all custom IBOutlet properties and populate data to the matching UI controls:

...

cellView.textField?.stringValue = currentPurchase.paymentInfo?.creditCard ?? ""
cellView.creditCardTypeLabel?.stringValue = currentPurchase.paymentInfo?.creditCardType ?? ""
cellView.amountLabel?.stringValue = currentPurchase.paymentInfo?.amount ?? ""

cellView.purchasesPopup?.removeAllItems()
cellView.purchasesPopup?.addItems(withTitles: currentPurchase.paymentInfo?.purchaseTypes ?? [])

return cellView

Regarding the popup button, note that it’s necessary first to remove the default items it contains prior to setting the new values.

At this point, you can delete the return nil line at the end of the method, as we return a value from each case of the condition now. The entire method at this point is this:

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
    let currentPurchase = viewModel.purchases[row]

    if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "idColumn") {

        let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "idCell")
        guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { return nil }
        cellView.textField?.integerValue = currentPurchase.id ?? 0
        return cellView

    } else if tableColumn?.identifier == NSUserInterfaceItemIdentifier(rawValue: "userInfoColumn") {

        let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "userInfoCell")
        guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { return nil }
        cellView.textField?.stringValue = currentPurchase.userInfo?.username ?? ""

        if let avatarData = viewModel.getAvatarData(forUserWithID: currentPurchase.userInfo?.id) {
            cellView.imageView?.image = NSImage(data: avatarData)
        }

        return cellView


    } else {

        let cellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "paymentInfoCell")
        guard let cellView = tableView.makeView(withIdentifier: cellIdentifier, owner: self) as? PaymentInfoCellView else { return nil }

        cellView.textField?.stringValue = currentPurchase.paymentInfo?.creditCard ?? ""
        cellView.creditCardTypeLabel?.stringValue = currentPurchase.paymentInfo?.creditCardType ?? ""
        cellView.amountLabel?.stringValue = currentPurchase.paymentInfo?.amount ?? ""

        cellView.purchasesPopup?.removeAllItems()
        cellView.purchasesPopup?.addItems(withTitles: currentPurchase.paymentInfo?.purchaseTypes ?? [])

        return cellView
    }
}

Run the app now; third column displays data too!

mt5_1_tableview_sample

Using Custom Views

The two approaches presented in the previous two parts of the tutorial on how to display data on a table view can cover the majority of use cases. Subclassing NSTableCellView is the most common way to go, while you have the cell’s UI controls laid out on the storyboard file. However, it might be more interesting or necessary sometimes to create custom views and design the cell’s UI in a separate XIB file.

In the starter project there is a pair of files, named PurchasesDetailView.swift and PurchasesDetailView.xib. Both files regard the implementation of a custom view which offers an alternative way to present data on each row of the table view. XIB file contains the UI designed in Interface Builder, and the contents of that XIB file are being loaded by the PurchasesDetailView class which also declares the IBOutlet properties that UI controls connect to. That custom view is ready to be used.

Note: PurchasesDetailView adopts the LoadableView protocol to load the contents of the XIB file. That protocol has been discussed on this previous tutorial where I presented how to deal with custom views and other interesting stuff, so take a look to find out more.

We will make the demo app capable of switching layout between the cells we currently have and the PurchasesDetailView custom view. We will achieve that when the button titled “Switch to Detail Display Mode” is clicked. The first thing we have to consider is the number of columns, as we currently have three of them, but when the custom view is used we will need just one. Of course, the opposite should happen when the button is clicked again to leave the detailed view mode and go back to displaying the current cells.

To achieve all that we are going to meet something new; how to remove and add table columns programmatically.

Switching Display Mode

Go to the switchDisplayMode(_:) IBAction method in the ViewController.swift file and add the following content:

@IBAction func switchDisplayMode(_ sender: Any) {
    viewModel.switchDisplayMode()

    if viewModel.displayMode == .detail {

    } else {

    }

    tableView.reloadData()
}

viewModel object contains a property called displayMode, a DisplayMode value. DisplayMode is an enumeration with two values: plain and detail and its purpose is to indicate whether the plain (current cells) or detailed (custom view) layout should be used to present data. The default value is plain.

switchDisplayMode() method shown above (implemented in the ViewModel class) switches between plain and detailed view, that’s why it’s the first step in the above method.

Next, we use an if statement to determine whether the current display mode is set to plain or detail. At the end we reload the table view, otherwise no visual changes will occur.

Focusing now on the if body, the first things we’ll do is to remove all current columns from the table view:

for column in tableView.tableColumns.reversed() {
    tableView.removeTableColumn(column)
}

tableColumns is a property in the tableView object. removeTableColumn is provided by the NSTableView class, and allows to remove existing columns.

Now, let’s create one new column programmatically:

let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "detailsColumn"))
column.width = tableView.frame.size.width
column.title = "Purchases Detailed View"

Notice that we make the column’s width equal to the width of the table view. Upon initialization we specify its identifier value, and at the end we give it a title too.

The new column must be appended to the table view:

tableView.addTableColumn(column)

That’s it, we managed to delete all previous columns and create a new one within a few lines of code.

Before we see the implementation of the else clause, let’s add one more line that will change the button’s title as necessary:

viewModeButton?.title = "Switch to Plain Display Mode"

Good! Now, let’s see what should happen in the else case. Here the columns and cells we currently have should be displayed again, so the new column that was created from the code above must be removed from the table view:

tableView.removeTableColumn(tableView.tableColumns[0])

The original columns can now be brought back to the table view:

for column in originalColumns {
    tableView.addTableColumn(column)
}

originalColumns is an array that holds the original columns of the table view. It’s already declared as a property in the ViewController class.

Lastly, update the button’s title accordingly:

viewModeButton?.title = "Switch to Detail Display Mode"

And the switchDisplayMode(_:) IBAction method is now ready:

@IBAction func switchDisplayMode(_ sender: Any) {
    viewModel.switchDisplayMode()

    if viewModel.displayMode == .detail {
        for column in tableView.tableColumns.reversed() {
            tableView.removeTableColumn(column)
        }

        let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "detailsColumn"))
        column.width = tableView.frame.size.width
        column.title = "Purchases Detailed View"
        tableView.addTableColumn(column)

        viewModeButton?.title = "Switch to Plain Display Mode"

    } else {
        tableView.removeTableColumn(tableView.tableColumns[0])

        for column in originalColumns {
            tableView.addTableColumn(column)
        }

        viewModeButton?.title = "Switch to Detail Display Mode"
    }

    tableView.reloadData()
}

One more thing: Go to the viewDidLoad() method and add the next line:

override func viewDidLoad() {
    ...

    originalColumns = tableView.tableColumns
}

That line is the one that stores the original columns to the originalColumns array.

Matching Display Mode And Content

Let’s jump into the tableView(_:viewFor:row:) table view delegate method now, where we will start with a big change. Select everything in the method’s body after the line:

let currentPurchase = viewModel.purchases[row]

Then cut it, and type the following:

if viewModel.displayMode == .plain {

} else {

}

In the if body paste the code you had previously cut. This is the case where the original columns and cells should be displayed to the table view. Next, go to the else case body. Using the custom view is simple as that:

let view = PurchasesDetailView()
view.usernameLabel.stringValue = currentPurchase.userInfo?.username ?? ""
view.idLabel.integerValue = currentPurchase.id ?? 0

if let avatarData = viewModel.getAvatarData(forUserWithID: currentPurchase.userInfo?.id) {
    view.avatarImageView.image = NSImage(data: avatarData)
}

view.creditCardNumberLabel.stringValue = currentPurchase.paymentInfo?.creditCard ?? ""
view.creditCardTypeLabel.stringValue = currentPurchase.paymentInfo?.creditCardType ?? ""
view.amountLabel.stringValue = currentPurchase.paymentInfo?.amount ?? ""
view.purchasesLabel.stringValue = currentPurchase.paymentInfo?.purchaseTypes?.joined(separator: ", ") ?? ""

return view

Since we have one column only in the detailed display mode, there’s no need to explicitly check its identifier as we did for the columns in the plain display mode. The fact that the tableView(_:viewFor:row:) delegate method returns a NSView object make is feasible to initialize a custom view object, to configure it and return it as shown above.

The row height must be updated also when the display mode gets changed:

func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
    if viewModel.displayMode == .plain {
        return 21.0
    } else {
        return 150.0
    }
}

Time to try out all the above! Run the app now and use the button to switch between display modes:

mt5_22_switch_display_mode

Handling Double Click

It’s quite common for users to double click on rows expecting for a certain action to be triggered by doing that. Knowing how to handle double clicks is important, as that enables developers to provide an expected by the users app behaviour and experience. One might would expect to see a table view delegate method that gets called when double clicking occurs, however this is not the case here.

NSTableView class contains a property called doubleAction. This property is a selector and a target method must be assigned to it, so whenever a double click action happens that method to be called. Obviously, that target method is the place where the double click related logic should be implemented.

To make everything clear let’s handle double click actions made in the table view of the demo app. We will add a few lines of code that will present an alert dialog displaying the username and the amount spent regarding the purchase information matching to the double-clicked row.

To start, go to the viewDidLoad() method and add the following line:

override func viewDidLoad() {
    ...

    tableView.doubleAction = #selector(handleDoubleClick)
}

With the above we are saying to our app that it should call the handleDoubleClick() method every time a user double clicks on our table view. The next step is to define that method as shown next:

@objc func handleDoubleClick() {

}

Since the above is a target method to the doubleAction selector it’s marked with the @objc keyword.

Getting the clicked row is easy:

@objc func handleDoubleClick() {
    let clickedRow = tableView.clickedRow

}

An important piece of information: doubleAction selector gets called when a double click action occurs in both a row and a column. When double click occurs on a column, the clickedRow property above has the -1 value. If its value is greater than or equal to 0 then double click took place on a row.

At this point remember that the purchases array in the viewModel class is the datasource of the table view, so a row matches to an item in the collection. Therefore getting the Purchases object matching to the double clicked row as a simple as that:

let purchase = viewModel.purchases[clickedRow]

We can now use the purchase object and present the alert:

showAlert(forPurchase: purchase)

showAlert(forPurchase:) method is already implemented in the ViewController class.

Here’s the handleDoubleClick() method at this point:

@objc func handleDoubleClick() {
    let clickedRow = tableView.clickedRow

    if clickedRow >= 0 {
        let purchase = viewModel.purchases[clickedRow]
        showAlert(forPurchase: purchase)
    }
}   

Accessing Row Contents

The above consists of the most simple way to acces the proper Purchases object and use it for getting the needed data for the alert. Now we’ll do the same, but in favour of the educational purposes of the tutorial we’ll follow a bit more complex and indirect way to achieve that. Through that process we’ll have the chance to meet more table view methods and APIs.

First off, let’s clarify what the goal is here: We will get the “ID” value from the text field in the table cell view of the first column in the double-clicked row, and based on that we will find the Purchases object that contains the data we want to show on the alert. As you understand, my purpose here is to show how to access columns and table cell views through the clicked row object.

Our app supports two different display modes (plain and detail), so we must handle double click actions for each mode separately:

if viewModel.displayMode == .plain {

} else {

}

Let’s start with the “plain” mode and let’s review the steps:

Once we make sure that clickedRow shows the index of a clicked row, we will then get that row as a NSTableRowView object:

let row = tableView.rowView(atRow: clickedRow, makeIfNecessary: false)

rowView(atRow:makeIfNecessary:) method returns a NSTableRowView object which represents the clicked (or selected) row programmatically. If no row view object can be created, then nil is being returned. However, if that method returns an actual value, then we can use the view(atColumn:) method of the NSTableRowView class as shown below and access any column we want:

let cellView = row.view(atColumn: 0) as? NSTableCellView

In our case we want the first column at index 0, as it contains the table cell view with the text field containing the ID value.

If cellView above is not nil, then we can easily get the ID we are looking for through its text field control:

let id = cellView.textField?.integerValue

Then, we can use that id value to get the Purchases object from the viewModel object:

let purchase = viewModel.getPurchase(withID: id)

getPurchase(withID:) method is already implemented in the ViewModel class, and it returns the Purchases object that contains all the information we need.

Since almost all the above steps can return nil values, we can gather them all in one big guard statement as shown next:

guard clickedRow >= 0,
    let row = tableView.rowView(atRow: clickedRow, makeIfNecessary: false),
    let cellView = row.view(atColumn: 0) as? NSTableCellView,
    let id = cellView.textField?.integerValue,
    let purchase = viewModel.getPurchase(withID: id)
    else { return }
}

Finally, all we have left to do is to present the alert with the proper message using the showAlert(forPurchase:) method:

showAlert(forPurchase: purchase)

To have the same results in the detail display mode, similar to above, yet simpler actions must be made as well. Initial steps remain the same, but this time we will cast the first column (the only column in detail display mode) to a PurchasesDetailView object. The actual ID value will be taken from the idLabel text field of the view, which will be used to get the Purchases object:

guard clickedRow >= 0,
    let row = tableView.rowView(atRow: clickedRow, makeIfNecessary: false),
    let view = row.view(atColumn: 0) as? PurchasesDetailView,
    let purchase = viewModel.getPurchase(withID: view.idLabel.integerValue)
    else { return }

showAlert(forPurchase: purchase)

Here’s the entire method implementation. Notice that the original solution has been commented out:

@objc func handleDoubleClick() {
    let clickedRow = tableView.clickedRow

    /*
    if clickedRow >= 0 {
        let purchase = viewModel.purchases[clickedRow]
        showAlert(forPurchase: purchase)
    }
    */

    if viewModel.displayMode == .plain {
        guard clickedRow >= 0,
            let row = tableView.rowView(atRow: clickedRow, makeIfNecessary: false),
            let cellView = row.view(atColumn: 0) as? NSTableCellView,
            let id = cellView.textField?.integerValue,
            let purchase = viewModel.getPurchase(withID: id)
            else { return }

        showAlert(forPurchase: purchase)
    } else {
        guard clickedRow >= 0,
            let row = tableView.rowView(atRow: clickedRow, makeIfNecessary: false),
            let view = row.view(atColumn: 0) as? PurchasesDetailView,
            let purchase = viewModel.getPurchase(withID: view.idLabel.integerValue)
            else { return }

        showAlert(forPurchase: purchase)
    }
}

Once again I underline the fact that the solution presented here is not the indicated one as the original solution remains the best approach. However, I used it as the means to discuss about more table view APIs, such as the rowView(atRow:makeIfNecessary:) and view(atColumn:) methods.

Run the app now and double click on any row. The username and the spent amount will be shown to an alert similar to that:

mt5_23_double_click_alert

Handling Single Selections

Being able to know when a single row has been selected is equally important to knowing when and where a double click action has occurred. Initiating additional actions based on the single selection is once again part of the experience users expect to find to a macOS app, so in this part we are focusing on this exactly: How to handle single row selections.

For the sake of the example we are going to make our demo application capable of displaying the amount shown on a selected row to the selectedAmountLabel label which exists to the bottom right side of the window. There is no real value in doing that, it’s perfect though for training purposes.

This time there is a table view delegate method that we can use and be notified when a row is being selected. But on the contrary to the most of the delegate methods, this one contains a notification object as a parameter value:

func tableViewSelectionDidChange(_ notification: Notification) {

}

This method is called every time a row is being selected on the table view. As a hint, keep in mind that the object property of the notification (notification.object) brings along the table view where the selected row belongs to. This is useful in cases where you are managing more than one table views in your view controller. Here we have one table view only, and we access it through the tableView IBOutlet property. We won’t use the notification’s object.

The first thing we have to do in the body of this method is to get the index of the selected row. In the previous part we met the clickedRow property of the table view. Now we will meet another one, the selectedRow:

func tableViewSelectionDidChange(_ notification: Notification) {
    let selectedRow = tableView.selectedRow

}

If no row is selected, then this property has the -1 value. If, however, multiple rows are selected, then it returns the index of the last row.

To check whether multiple rows are selected or not, there is another available property to use named selectedRowIndexes. It returns an IndexSet with the index values of all selected rows. Since it’s a set we can use the count property and determine how many rows are selected. For example, the following will be executed if only one row is selected:

if tableView.selectedRowIndexes.count == 1 {
    // Do something...
}

Note: Using the selectedRowIndexes property is meaningless in case you have not enabled multiple row selection in your table view. The number of selected rows will always be 1.

Back to our demo app, let’s write down the conditions that will allow to get the amount when a single row is being selected:

if selectedRow >= 0 && tableView.selectedRowIndexes.count == 1 {

}

Getting the amount we need is simple after that:

if let amount = viewModel.purchases[selectedRow].paymentInfo?.amount {
    selectedAmountLabel?.stringValue = "Selected amount: \(amount)"
}

Here is the method as a single piece:

func tableViewSelectionDidChange(_ notification: Notification) {
    let selectedRow = tableView.selectedRow

    if selectedRow >= 0 && tableView.selectedRowIndexes.count == 1 {
        if let amount = viewModel.purchases[selectedRow].paymentInfo?.amount {
            selectedAmountLabel?.stringValue = "Selected amount: \(amount)"
        }
    }    
}

Run the app now, and select any row to see the amount of the selected row also displayed to the bottom right side label:

mt5_24_select_row_amount

Handling Row Actions

Row actions are those buttons displayed on the left or right side of a row when that row is swiped towards the opposite direction. Best example is the Mail application where by swiping on the right we can mark a message as read or unread, and by swiping to the left we can delete it, flag it, etc.

Presenting such buttons in our own table view is easy, as all it takes is to implement a delegate method. In it, we can return different row action buttons for each edge of the row (leading, trailing).

To make it specific, let’s implement this scenario: When swiping to right, we’ll display a button on the leading edge that will let us print the data presented in the selected row. Of course, no real printing will take place. We will just show a message on the console. When swiping to left, we’ll show an action on the trailing edge that will let us delete a row. The equivalent Purchases object will be removed from the purchases array in the ViewModel class as well.

Let’s get started by defining the following table view delegate method:

func tableView(_ tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableView.RowActionEdge) -> [NSTableViewRowAction] {

}

An array of NSTableViewRowAction items must be returned by this method. Since we want different actions on each edge, we’ll return two different arrays.

The first step now is to distinguish the edge:

if edge == .leading {

} else {

}

The leading edge represents the left side of the row, and it’s revealed when the row is being swiped to the right. Let’s create the row action for that edge:

let printAction = NSTableViewRowAction(style: .regular, title: "Print") { (action, index) in
    print("Now printing...")
}

In the closure body we add the code that should be implemented when that action button gets clicked. In our case, just printing the above message on the console is good enough. Notice that the printAction above has been given the “regular” style, and by default regular actions have a blue background color. We can change it as simply as that:

printAction.backgroundColor = NSColor.gray

In a similar fashion you can add more row actions if you want. Regardless if it’s just one or more actions, an array is what it should be returned:

return [printAction]

Now, let’s create and return the delete action that will let us remove the selected row:

let deleteAction = NSTableViewRowAction(style: .destructive, title: "Delete") { (action, index) in
    self.viewModel.removePurchase(atIndex: row)
    self.tableView.reloadData()
}

return [deleteAction]

We mark this action as a destructive one, and the action button’s background color will be red. Also, in this case we perform real actions since we remove the selected Purchases object using the removePurchase(atIndex:) method (implemented in the ViewModel class) and then we reload the table view to update the displayed rows based on the datasource (purchases array in ViewModel).

Our new delegate method is ready:

func tableView(_ tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableView.RowActionEdge) -> [NSTableViewRowAction] {

    if edge == .leading {
        let printAction = NSTableViewRowAction(style: .regular, title: "Print") { (action, index) in
            print("Now printing...")
        }
        printAction.backgroundColor = NSColor.gray
        return [printAction]

    } else {
        let deleteAction = NSTableViewRowAction(style: .destructive, title: "Delete") { (action, index) in
            self.viewModel.removePurchase(atIndex: row)
            self.tableView.reloadData()
        }

        return [deleteAction]
    }
}

Note that the above will work in both display modes we support. Run the app now and swipe any row in both directions to see and use the row actions:

mt5_25_row_actions

Allow & Disallow Selecting Rows

Most of our actions on the table view so far are based on the fact that we can select rows, but this is not always desirable. You might need to present data on a table view but to prevent row selection. To achieve that you need to implement the following table view delegate method:

func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {

}

If you return true, then row selection is allowed. If you return false, users won’t be able to select rows.

If you want to try it out, just return false and run the app:

func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
    return false
}

You will see that no row can be selected. Be cautious however: Single row click might gets “disabled” that way, but double click is still working.

Make sure you return true from the above method when you finish testing it.

Sorting Displayed Data

A feature that we often meet in macOS applications which contain table views is the ability to sort the displayed data when clicking on a column title. This behaviour is useful if only it makes sense for your app to allow data sorting, so it’s not something that should be done no matter what.

In the demo app of this tutorial we have several columns that we can use to sort data. However, we’ll keep things simple and short, and we’ll allow sorting only when the ID column is clicked (in plain display mode). If you desire, you can modify later the app and support sorting through other columns as well.

So, to get started, in the ViewController class add the following method definition:

func setSortDescriptor() {

}

Two things are going to happen in it: First, we will initialize a NSSortDescriptor object to describe the order (ascending, descending) and the name of the property where we’ll base the sorting on. Second, we’ll assign that sort descriptor object to the first column of the table view (ID column).

func setSortDescriptor() {
    let idDescriptor = NSSortDescriptor(key: "id", ascending: true)
    tableView.tableColumns[0].sortDescriptorPrototype = idDescriptor
}

The idDescriptor sort descriptor is assigned to the sortDescriptorPrototype property of the table column. Notice the index 0 that indicates the first column of the table view.

Now let’s call this method. Right before the closing of the viewDidLoad() method add this:

override func viewDidLoad() {
    ...

    setSortDescriptor()
}

The above however are not enough to make sorting work. We need to implement a table view datasource method:

func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {

}

This is where we will apply the logic to sort data. The most important thing we’ll do is to get the sort order from the descriptor and sort the data accordingly. After that, we just need to reload the table view:

func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
    guard let sortDescriptor = tableView.sortDescriptors.first else { return }
    viewModel.sortPurchases(ascending: sortDescriptor.ascending)
    tableView.reloadData()
}

sortPurchases(ascending:) method is already implemented in the ViewModel class. We pass as an argument the sort order from the sort descriptor (sortDescriptor.ascending property).

Feel free to modify all the above and support more sort descriptors. If you run the app now and you click on the ID column you will see the displayed data being sorted, while a visual indication on the column shows the sort order.

mt5_26_sorted_tableview

Summary

All topics and concepts we met in this macOS tutorial today consist of the most vital and fundamental knowledge on table views that all new macOS developers should have. Through the demo application and a few simple examples we focused on the most important and most common aspects that come up when working with table views. I encourage you to keep exploring on this topic, and find more interesting delegate and datasource methods among all those being available that were not discussed here. Thanks for reading and I hope you liked this post. See you soon with another interesting topic coming next!

For reference, you can download the full Xcode project on GitHub.

Read next