Swift NSTable Column Chooser
Swift NSTable Column Chooser
Although I wrote NeXTStep programs back in the 1980s for my dissertation, I haven’t written many Cocoa programs. The extra Apple Developer fee for OSX apps was something I didn’t want to pay in addition to the iOS fee. OK, I’m cheap. But how many bags of cat food would that $99 buy?
At WWDC15, Apple dropped the additional $99 fee. So, I’m guessing that there are some iOS developers out there adding Cocoa to their bag of trix. This is aimed at iOS developers, so if you’re a beginning beginner, some things might be vague. Sorry.
Introduction
If you’re familiar with the UITable class and its minions, the NSTable should be fairly easy for you. We will make a Cocoa Application project with a storyboard, add an NSTable to the storyboard, and then write the code.
Project setup
Create a new Cocoa Application project. Open the storyboard, and drag a Table View to the ViewController. This is pretty much like what you do in iOS. The storyboard will put the table view inside a scroll view automatically. Select the table view and the attributes inspector. Set the number of columns to 3 (our struct has 3 fields). Note that the “content mode” is set by default to “View Based”. More on that in a bit.
Optional: one time saver is the “autosave” option a bit lower down on the attribute inspector. Simply check the box and make up a user defaults name and it will remember the user’s preference for column size and order.
For each column in the table, you need to set the identifier in the identity inspector (⌥–⌘-3) and title in the attributes inspector (⌥–⌘-4). The identifiers I use are the names of the fields in the data struct so I don’t confuse myself.
NSTable setup
I’m going to make the ViewController a table data source and delegate. So it will needs some data. Here’s a simple struct with some data (more in the github example).
Table data
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct Person { var givenName:String var familyName:String var age = 0 init(givenName:String, familyName:String, age:Int) { self.givenName = givenName self.familyName = familyName self.age = age } } /// the data for the table var dataArray = [Person]() override func viewDidLoad() { super.viewDidLoad() // create some people dataArray.append(Person(givenName: "Noah", familyName: "Vale", age: 72)) etc. |
Now, the table protocols. The NSTableViewDataSource will return the data. Sort of. There are a few ways to configure an NSTableView : “Cell Based” and “View Based”. The latter seems to be more flexible and it’s the default setting, so I’ll use that. If you opt for “Cell Based”, then the NSTableViewDataSource will need the objectValueForTableColumn function.
1 2 3 4 5 6 |
// MARK: - NSTableViewDataSource extension ViewController: NSTableViewDataSource { func numberOfRowsInTableView(aTableView: NSTableView) -> Int { return dataArray.count } } |
For our View Based table, the delegate will return the data via viewForTableColumn.
We didn’t change anything in the storybaord configuration for the table cells. We just said we wanted 3 of something. That something for a View Based table is an NSTableCellView. You create a reusable cell view via tableView.makeViewWithIdentifier. The I retrieve the appropriate data from the array, and set each view’s text field to that data by looked at the column identifier.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// MARK: - NSTableViewDelegate extension ViewController: NSTableViewDelegate { func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? { if let column = tableColumn { if let cellView = tableView.makeViewWithIdentifier(column.identifier, owner: self) as? NSTableCellView { let person = dataArray[row] if column.identifier == "givenName" { cellView.textField?.stringValue = "\(person.givenName)" return cellView } if column.identifier == "familyName" { cellView.textField?.stringValue = "\(person.familyName)" return cellView } if column.identifier == "age" { cellView.textField?.stringValue = "\(person.age)" return cellView } return cellView } } return nil } } |
Control-Drag from the table to the view controller (twice) to set the data source and delegate.
At this point, your table should work.
Run the project and see what happens.
Context Menu
To make the column selection work, I will create a context menu and attach it to the header of the table. I call this from viewDidLoad.
Let’s create the context menu first.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
/// set up the table header context menu for choosing the columns. func createTableContextMenu() { let tableHeaderContextMenu = NSMenu(title:"Select Columns") let tableColumns = self.tableView.tableColumns for column in tableColumns { let title = column.headerCell.title if let item = tableHeaderContextMenu.addItemWithTitle(title, action:"contextMenuSelected:", keyEquivalent: "") { item.target = self item.representedObject = column item.state = NSOnState if let dict = NSUserDefaults.standardUserDefaults().dictionaryForKey(kUserDefaultsKeyVisibleColumns) as? [String : Bool] { if let hidden = dict[column.identifier] { column.hidden = hidden } } item.state = column.hidden ? NSOffState : NSOnState } } self.tableView.headerView?.menu = tableHeaderContextMenu } |
So, I create an NSMenu then iterate over the table columns and create an NSMenuItem via addItemWithTitle. Each item will have its action set to the contextMenuSelected function I’ll talk about next. I check the user defaults to see if a column identified by its identifiers is hidden or not and then set the state appropriately. I show how to save the defaults later.
The action for each NSMenuItem toggles the value of the column’s hidden property and also the state of the menu item to match. If you’ve hidden a column, there is screen real estate that needs to be dealt with. There are two things you can do. Tell the table view to siteToFit or size just the last column. I don’t know which one I like better. Try them both and see what you like.
Finally, the state of the column’s hidden property needs to be save in user defaults. So I call a func to do that. Let’s look at that next.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/// The table action. `addItemWithTitle` specifies this func. func contextMenuSelected(menu:NSMenuItem) { if let column = menu.representedObject as? NSTableColumn { let shouldHide = !column.hidden column.hidden = shouldHide menu.state = column.hidden ? NSOffState: NSOnState if shouldHide { // haven't decided which I like better. // tableView.sizeLastColumnToFit() tableView.sizeToFit() } else { tableView.sizeToFit() } } self.saveTableColumnDefaults() } |
To save the value of the hidden property of each table column, I create a Dictionary with the column identifier as the key and the hidden property as the value. Then I just save the Dictionary in user defaults. Easy.
Before I knew about the auto column save feature I mentioned, I saved a Dictionary of Dictionaries to save multiple column properties. The auto feature saves some code.
1 2 3 4 5 6 7 8 9 |
/// Writes the selection to user defaults. Called every time an item is chosen. func saveTableColumnDefaults() { var dict = [String : Bool]() let tableColumns = self.tableView.tableColumns for column:NSTableColumn in tableColumns { dict[column.identifier] = column.hidden } NSUserDefaults.standardUserDefaults().setObject(dict, forKey: kUserDefaultsKeyVisibleColumns) } |
In the AppDelegate, you need to register the user defaults. In real life I’d use a plist. Here I’m just hard coding it.
1 2 3 4 5 6 7 8 9 10 11 |
func applicationDidFinishLaunching(aNotification: NSNotification) { // Register user defaults. Use a plist in real life. var dict = [String : Bool]() dict["givenName"] = false dict["familyName"] = false dict["age"] = false var defaults = [String:AnyObject]() defaults[kUserDefaultsKeyVisibleColumns] = dict NSUserDefaults.standardUserDefaults().registerDefaults(defaults) } |
You can check the user defaults in the Terminal. Use the bundle identifier. I set mine to com.rockhoppertech.TableColumnChooser. Look at the General tab of your project to find yours.
1 |
shellPrompt$: defaults read com.rockhoppertech.TableColumnChooser |
What you should see are the non-default values for the user defaults. If you haven’t hidden any columns, you will see only the column autosave values here. Go ahead, hide a column and come back to check.
Summary
It really isn’t hard to create a usable NSTableView and off the user the option of hiding certain columns. You just need to install a context NSMenu and set the hidden property of the NSColumn, then save the values in user defaults.
You can bind a special controller to the table view for it to receive its data. I’ll talk about “Cocoa Bindings” next time.
Hi
I have found your tutorial very interesting but as a self-taught coder I am slightly stuck as I need to save the data array. Can you please advise how to save and retrieve the data below to default preferences. I have tried various combinations after the as! Including these:-
let preferences: UserDefaults = UserDefaults.standard
preferences.set(dataArray, forKey: “dataArray_Prefs”) as! [Person[String:Any]]
dataArray = preferences.array(forKey: “dataArray_Prefs”) as! [Person[String:Any]]
From your tutorial:-
// create some people
dataArray.append(Person(givenName: “Noah”, familyName: “Vale”, age: 72))
dataArray.append(Person(givenName: “Sarah”, familyName: “Yayvo”, age: 29))
……….
1) Your Person needs to conform to NSCoding.
2) Encode it:
3) save the encoded data to user defaults
Thanks for the quick response.
I tried adding the suggested line of code to your tutorial and Xcode gave errors so I let it auto correct and ended up with :-
dataArray.append(Person(givenName: “Woody”, familyName: “Forrest”, age: 62))
dataArray.append(Person(givenName: “X.”, familyName: “Benedict”, age: 88))
let encodedData = NSKeyedArchiver.archivedData(withRootObject: Person(givenName: , familyName: , age: ))
but compiler still complaining thus :-
TableColumnChooser-master/TableColumnChooser/ViewController.swift:75:90: Editor placeholder in source file
I then typed strings and number into code and that cleared errors but don’t think this is what I need. I want to save to Default preferences ALL the rows of data not just one person.
Can you offer any more help please?
You used autocomplete and Xcode inserted a “helpful” placeholder. It has a darker background. You replace this with your actual code.
Sorry I don’t understand how your code snippet works, if I add your line:-
let encodedData = NSKeyedArchiver.archivedData(withRootObject: person)
Firstly is ‘person’ a typo and it should be ‘Person’? I assume so but this line compiler prompts me to add self or highlighted placeholder is “Add arguments after the type to construct a value of the type” which gives me:-
let encodedData = NSKeyedArchiver.archivedData(withRootObject: Person())
then it prompts again and creates this:-
let encodedData = NSKeyedArchiver.archivedData(withRootObject: Person(givenName: ))
which it then repeats for each variable.
If I except self option:-
let encodedData = NSKeyedArchiver.archivedData(withRootObject: Person.self)
it compiles without errors but when app runs tableview is completely empty.
Could I please ask if you can send a snippet from you tutorial with this feature to save the dataArray (and how I get it later in my app) included. Alternatively as I understand a bit about data arrays and use this to load example data on first run:-
var storedDataArray:[[String:Any]] = []
storedDataArray.append([“MyString”: “Some words”, “FileName”: “TestFile”, “version”: 1, “saved”: false])
storedDataArray.append([“MyString “: “More words”, “FileName”: “TestFileTwo”, “version”: 3, “saved”: true]). I think I only need your code data structure to use the “sort columns” feature, so how can I modify the code from your tutorial to read data from my storedDataArray? I don’t understand how column sorting works and what “let l = lhs.familyName.characters” etc is actually doing
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
if sortDescriptor.key == “familyName” {
if sortDescriptor.ascending {
self.dataArray = dataArray.sorted {lhs, rhs in
let l = lhs.familyName.characters
let r = rhs.familyName.characters
return l.lexicographicallyPrecedes(r)
}
} else {
self.dataArray = dataArray.sorted {lhs, rhs in
let l = lhs.familyName.characters
let r = rhs.familyName.characters
return !l.lexicographicallyPrecedes(r)
}
}
}
if sortDescriptor.key == “givenName” {
……………….
I hope you understand me as it’s difficult to explain.
Andrew
Hi Gene
Can you offer any help please to use storedDataArray format :-
“var storedDataArray = [[String: Any]]()
storedDataArray.append([“MyString”: “Some words”, “FileName”: “TestFile”, “version”: 1, “saved”: false])
storedDataArray.append([“MyString “: “More words”, “FileName”: “TestFileTwo”, “version”: 3, “saved”: true])”
to place data in your column sorting code:-
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
if sortDescriptor.key == “familyName” {
if sortDescriptor.ascending {
self.dataArray = dataArray.sorted {lhs, rhs in
let l = lhs.familyName.characters
let r = rhs.familyName.characters
return l.lexicographicallyPrecedes(r)
}
} else {
self.dataArray = dataArray.sorted {lhs, rhs in
let l = lhs.familyName.characters
let r = rhs.familyName.characters
return !l.lexicographicallyPrecedes(r)
}
}
}
Thanks Andrew
Hi Gene
Can you offer any help please as I am now stuck.
Thanks