MacDevCenter    
 Published on MacDevCenter (http://www.macdevcenter.com/)
 See this if you're having trouble printing code examples


Programming With Cocoa

Adding a Preferences Window to Your Application

09/17/2001

Up to this point our applications have been single window applications (SimpleTextEditor was actually a multi-window application by virtue of it being document-based. In that situation, the machinery of the document-based application took care of handling multiple windows).

The focus of this article will be to add a Preferences window to the Address Book application, which is a staple of any application. This preferences window will allow the user of Address Book to select which columns he wishes to be displayed in the table.

All of what we do today will be based on the Address Book project from the end of the last column. You can download it here.

We've got a lot of ground to cover in this column, so without further ado, we commence.

Interface Builder! Once again!

Before things get too busy here, we're going to get the essential task of building the preference window interface out of the way. Since we've built many interfaces already in this series of columns, I am going to give only a minimum of direction on constructing the Preferences panel, with particular emphasis on things that are new or different.

The Preferences window is just that, another window. Interface Builder makes the job of adding a window to an application painless by providing a palette of windows that we can add to our application. The palette actually has four objects on it; they are a standard window, a panel type window, a window with a drawer, and then a drawer, which can be added to a window.

Screen shot.
The Windows Palette in Interface Builder

Let's focus for a minute on the standard window and the panel. Window is just a window -- nothing special about it. Panels on the other hand are a special kind of window. The defining class of panel behavior, NSPanel, is in fact a subclass of NSWindow.

NSPanel modifies the behavior of standard windows in several ways. Some of these may sound familiar from your day-to-day interaction with Mac OS X, so I won't go into a ton of detail. For example, when you are running an application with a panel open, and then you switch to another application, the panel will disappear, whereas windows don't. This helps to reduce screen clutter. You can see this in Interface Builder; when you click on the finder, the Palettes panel and Get Info panel will disappear. For more information about panels, see the NSPanel class reference.

Most applications use NSWindow as their preferences window, and we'll follow suit here. All you have to do to add a new window to your application is to drag it off the palette anywhere onto the screen. This will add the window instance to your nib file under the "Instances" tab, and display the open window on screen. In the "Instances" tab it is possible to change the name of the window icons to whatever you like. To help distinguish between our two windows, rename Window (the one with the table) to mainWindow, and Window1 (the one we just added) to prefsWindow. Simply double-clicking on the name does this.

We will occupy our preferences window with six check boxes, where each check box corresponds to a potential table column. Check boxes can be found on the same Interface Builder palette as text fields and buttons carrying the name "Switch"; drag six of them onto the preferences window. Give the check boxes the following names, which incidentally are the names of our columns and the column identifiers (make sure that these names do indeed match your column titles and identifiers):

Yes, I realize we haven't had "Work Phone" and "Mobile Phone" up to this point. Enabling your application to handle these additional data fields should be trivial following the example of the article that first introduced us to table views. You should only need to add the appropriate interface elements and modify the method createRecord. I leave the addition of these two data fields and the respective interface elements (i.e., input text fields) as an exercise for when you have the time or the inclination. Don't create the matching columns though. Leave those as they are.

One last control we're going to add is a "Close" button. Drag a regular button onto the preferences window and change the name to "Close".

Below is an image of my preferences window as I have laid it out.

Screen shot.

To give our code access to these new controls, we have to add an outlet for each of the check boxes. Add to the Controller class the following six outlets corresponding to the six check boxes, and make the appropriate connections:

Comment on this articleFor those of you working on this project, let's talk about how things are going.
Post your comments

We also have to add an action method to Controller that is invoked whenever the user changes the state of any of the check boxes. We'll call this action method setColumn because it will contain code that will add or remove columns from the table view in response to the user selecting or deselecting a check box.

We want all six of our check boxes to invoke this method whenever they are changed, so we connect all six check boxes to this single action method of Controller. Drag a wire and make a connection from each check box to Controller. It's a perfectly acceptable practice to have many controls invoke a common action method. When it comes time to coding setColumn, we will use the sender argument that is provided in all action methods to determine which check box sent the message.

Standard operating procedure for applications is that a preferences window will open when the user selects "Preferences..." from the application menu. To enable this we will make a connection between the "Preferences..." menu item and predefined action method of prefsWindow called makeKeyAndOrderFront:. Wiring menu items is just like wiring any other control to an action.

Drag a wire from "Preferences" under "NewApplication" in the menu bar to the preferences window icon, prefsWindow. In the Info window select makeKeyAndOrderFront: in the actions column and click connect (by the way, you can make a connection by double-clicking the action name as well. My roommate just showed me this, and it's quite convenient). Note that depending on the size of your info window, the action name makeKeyAndOrderFront: may be truncated.

Screenshot.
Wiring the preferences menu item to the preferences window action makeKeyAndOrderFront:

Finally, we have to wire our "Close" button to some action that closes the window. This is by dragging a wire from the Close button to prefsWindow -- in the list of Actions, choose performClose:. This action simulates a click on the window's close button.

Screenshot.
Connecting the close button to the preferences window action performClose:

When you're done setting up the interface, do not create files for Controller, we've already got them and to do so would overwrite all of our previous work. Rather, go back to Project Builder and add the outlets and action declaration to Controller.h manually (we'll fix up Controller.m later on). So in Controller.h add the following six lines to the instance variable declaration block:

    IBOutlet id firstNameCB;
    IBOutlet id lastNameCB;
    IBOutlet id emailCB;
    IBOutlet id homePhoneCB;
    IBOutlet id workPhoneCB;
    IBOutlet id mobilePhoneCB;

And to the method declarations add:

- (void)setColumn:(id)sender;

and that should do it! Now on to the coding.

The coding

To kick off the coding discussion, let's go over what exactly is going to happen with the table column preferences and how they will affect the table view, as well as our strategy for implementing this.

Our strategy

We have seen that the Preferences window consists of six switches that indicate which columns are present in the table, and allows the user to add and remove columns from the table. Six different column choices means that there are two more columns than we initially set up in the table view. We are not going to create a fifth and sixth table column for the table view in Interface Builder, rather, we will do this in code. Everything we do from here on out will be done with code.

At the user-level, the preferences check boxes will function in the following way: When a user selects a previously unselected check box, the indicated table column is immediately added to the table view. When the user selects a check box that is already active, then the indicated column will be removed from the table view.

Let's now discuss the different parts we will need to code to make this work -- our strategy. First, we must have a collection of prebuilt NSTableColumn objects (yes, you can have a table column that is not part of a table and is not shown in the interface) that is created when the application launches.

Second, after the collection of prebuilt objects has been created, the application will look up in the user defaults database a list of columns that the user may have previously selected to include in his table view. If this list exists, the application will reconfigure the table to contain only these columns. If no such list exists, then the application will do nothing and use the table view configuration established in Interface Builder as a default setup.

At this point it will be necessary to set the initial states of the check boxes to match the columns in the tableView, so we'll do that too. Finally, we have to set up code that will add or remove table columns in response to the user changing preferences.

First, a collection of prebuilt table columns

We stated above in our strategy that the application should create a set of prebuilt NSTableColumn instances. So how do we go about this and more importantly how do we store this collection of table columns? Ideally we would want to access prebuilt columns by their identifier, so an NSDictionary would suit us well.

In practice, when the application needs a table column, it will pass the dictionary of table columns a key which is the identifier of the column we need, and the collection will then return the matching NSTableColumn object. So let's take a shot at creating new columns and populating a dictionary with several of them when the application launches (and it needs to be one of the first things done, as the initial configuration of the table will depend on being able to access these table column objects). Before we start, declare in Controller.h the NSMutableDictionary instance variable tableColumns:

NSMutableDictionary *tableColumns;

And we initialize this instance variable in awakeFromNib in the standard fashion:

tableColumns = [[NSMutableDictionary alloc] init];

Now, what follows is the code to create one new instance of NSTableColumn, configure it, and add it to tableColumns; we'll create the "First Name" NSTableColumn.

NSTableColumn *newColumn;

newColumn = [[[NSTableColumn alloc] initWithIdentifier:@"First Name"] autorelease];
[[newColumn headerCell] setStringValue:@"First Name"];
[[newColumn headerCell] setAlignment:NSCenterTextAlignment];
[newColumn setEditable:YES];
[tableColumns setObject:newColumn forKey:@"First Name"];

First we declared a variable to temporarily store our NSTableColumn, newColumn. In the next line of code, we simply created a new NSTableColumn instance using alloc, and then initialized it using NSTableColumn's initializer method initWithIdentifier:. The argument to this method is of course the identifier we want our table column to have, which we had previously set from within Interface Builder. By autoreleasing, we're saying that we don't want to be responsible for the new table column object any longer than we need to be -- we'll let the dictionary tableColumns be responsible for all of the table columns when we add them to the collection (remember, collections sent newly added member objects retain messages, thereby asserting ownership over the member object).

The next line of code sets the title of the table column, which as you can see is the same as the identifier. Notice the way that line of code is set up. We first send a headerCell message to newColumn, which returns the object that represents the header of the column. We can't tell tableColumn to make its title such-and-such, because that's not the job of NSTableColumn. NSTableColumn relies on instances of NSTableHeaderCell to take care of that kind of stuff. The method used here, setStringValue: is declared in the class NSCell, from which NSTableHeaderCell inherits.

Following that line, we set the alignment of the title in the column's header cell. There are five possibilities for aligning text in a cell represented by five constants -- one of which you see in the code above. These five possibilities and their corresponding constants are as follows:

Alignment Mode Constant
Right NSRightTextAlignment
Left NSLeftTextAlignment
Center NSCenterTextAlignment
Justified NSJustifiedTextAlignment
The Default NSNaturalTextAlignment

As you can see above I've decided to align my column titles so that they are centered.

In the next line of code we go back to modifying the table column itself by setting whether or not it is editable, which refers to whether or not we can modify the data from within the column itself. Finally, to finish things off, we add our configured column to the tableColumns dictionary, with the column identifier as the key. Now whenever we send an objectForKey:@"First Name" message to tableColumns this column we created will be returned.

If you wish to configure your columns further, then you can put any additional configuration code between the initialization line and the line where we add it to the dictionary. For all of the possibilities, as always, check out the class documentation for NSTableColumn and NSTableHeaderCell (which will lead you up the class hierarchy to their various parent classes).

Now all we have to do is cut and paste this code for each of the five remaining columns, and change the identifier string in each one. Hah! That would be too much busy work and it is much too inflexible for our tastes, so we continue on to modify this foundation block of code.

One modification we could make would be to encase this code in a new method that takes the identifier as an argument. So rather than cutting and pasting this large block of code we could simply invoke this new method six times, each time changing the identifier argument. This method is called addInitialColumnForIdentifier: ("add..." in the sense that we are adding a new column to the tableColumns dictionary) , and would look like the following:

- (void)addInitialColumnForIdentifier:(NSString *)identifier
{
  NSTableColumn *newColumn;

  newColumn = [[[NSTableColumn alloc] initWithIdentifier:identifier] autorelease];
  [[newColumn headerCell] setStringValue:identifier];
  [[newColumn headerCell] setAlignment:NSCenterTextAlignment];
  [newColumn setEditable:YES];
  [tableColumns setObject:newColumn forKey:identifier];
}

All I did here was copy the original code into the method definition, and change all the static @"First Name" identifier strings to the variable identifier. Now in awakeFromNib when we want to fill tableColumns with our six columns, we need only invoke this method six times, changing the identifier:

[self addInitialColumnForIdentifier:@"First Name"];
[self addInitialColumnForIdentifier:@"Last Name"];
[self addInitialColumnForIdentifier:@"Email"];
[self addInitialColumnForIdentifier:@"Home Phone"];
[self addInitialColumnForIdentifier:@"Work Phone"];
[self addInitialColumnForIdentifier:@"Mobile Phone"];

So, our code is a little more appealing to the intellect (at least in my mind) and a little bit more flexible, but I contend that we can do even better. Why should we hard-code six calls to this method, when we know of a way to automatically do something like that with enumerators? Yes my friends, enumerators.

Consider that rather than making six calls to addInitialColumnForIdentifier, we create an array of identifier strings; call this array identifiers. Then we could get create an enumerator for that array, use the enumerator in a while loop, and within that loop send an addInitialColumnForIdentifier message to self. Here, look at the code that we could have:

NSArray *identifiers = [NSArray arrayWithObjects:@"First Name", @"Last Name", @"Email", @"Home Phone", @"Work Phone", @"Mobile Phone", nil];

id identifier;
NSEnumerator *e = [identifiers objectEnumerator];

while ( (identifier = [e nextObject]) ) {
  [self addInitialColumnForIdentifier:identifier];
}

Following our trend of creating new methods to encapsulate new logic, let's make a method similar to addInitialColumnForIdentifier: called addInitialColumnsForIdentifiers: whose argument is an array of identifier strings:

- (void)addInitialColumnsForIdentifiers:(NSArray *)identifiers
{
  id identifier;
  NSEnumerator *e = [identifiers objectEnumerator];

  while ( (identifier = [e nextObject]) ) {
    [self addInitialColumnForIdentifier:identifier];
  }
}

and then in awakeForNib we need only write:

NSArray *identifiers; // At the beginning of awakeFromNib

identifiers = [NSArray arrayWithObjects:@"First Name", @"Last Name", @"Email", @"Home Phone", @"Work Phone", @"Mobile Phone", nil];
[self addInitialColumnsForIdentifiers:identifiers];

And we have abstracted the decision of which columns we want to include a bit further away from the code that fills tableColumns, and I say that makes things more flexible, and the possibilities more interesting.

For example, no one said we had to statically create the identifiers array like we did above. We could instead initialize identifiers from a file. Yes ... that would make our application quite flexible indeed. Think about it. If we implemented our code in such a way, nowhere in our code would we see what the column identifiers are -- rather, they are defined in an external file. The decision on what columns to create comes when the user runs the application, not when it is compiled

.

The clever developer could extend this concept a bit further to dynamically create the data entry form and the table column preference check boxes at run time based on the column identifiers contained in this external file. Then, the clever user could tailor this application to organize and display whatever data fields he wishes (since really it's just a matter of what we name the data fields and table columns, right?) by mucking around with the file that contains the identifier declarations.

Let's change this code one more time to load the identifiers array from a file, using the class NSBundle.

Bundles

In the last column, we learned how we could initialize arrays from arbitrary files. Now I want to expand on this concept and tell you how you can initialize arrays from files that are resources within an application bundle. Bundles are one of the wonderful new things about Mac OS X. Essentially a bundle is a directory that contains all of the resources an application needs to run, including special images, and sounds, the nib files, the actual executable, and any number of configuration files. In the Finder, a bundle appears as an executable for the application. It is the .app extension of an application bundle that the finder recognizes so that the contents of the bundle should not be displayed to the user. This keeps your system cleaner and more organized, and reduces the risk that a mischievous user might inadvertently delete some critical application file.

Cocoa provides an interface in the AppKit class NSBundle for accessing resources contained within an application's bundle. The array identifiers will be initialized using initWithContentsOfFile: in the same way we learned to do so previously. Rather than declaring a static path, use NSBundle's pathForResource:ofType: which locates the indicated resource in the application bundle and returns the absolute path. pathForResource:ofType: takes two arguments: the name of the resource, and the file extension, or type of the resource. This file will be named Identifiers.plist.

We know the name of the resource we wish to access -- Identifiers -- as well as the file type of this resource -- plist. We need to have an NSBundle instance to send messages to, which is created using the class method mainBundle. Let's see how this all looks in code:

NSBundle *bundle;	// These first three lines go
                    // at the beginning of awakeFromNib
NSString *path;
NSArray *identifiers;

bundle = [NSBundle mainBundle];
path = [bundle pathForResource:@"Identifiers"
       ofType:@"plist"];
identifiers = [[NSArray alloc] 
               initWithContentsOfFile:path];
[self addInitialColumnsForIdentifiers:identifiers];

And that's how we can access resources contained within a bundle. Obviously there is much more we can do with NSBundle, but I just wanted to briefly introduce you to the possibilities. For the complete capabilities of NSBundle refer to its class documentation.

One last important thing we have to do to wrap up the bundle implementation of step one of our strategy is create the actual resource from which the array identifiers will be initialized from. This is easily done by creating a new empty file in your project; call this file Identifiers.plist; it will automatically be placed in your "Resources" group, and it will automatically be placed in the application bundle when you compile. The contents of this file will be the following:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist SYSTEM "file://localhost/System/Library/DTDs/PropertyList.dtd">
<plist version="0.9">
<array>
  <string>First Name</string>
  <string>Last Name</string>
  <string>Email</string>
  <string>Home Phone</string>
  <string>Work Phone</string>
  <string>Mobile Phone</string>
</array>
</plist>

This is just a standard XML property list representation for an array of strings, that is functionally equivalent to the static array initialization line we had using initWithObjects:.

At this point awakeFromNib should look like the code below:

- (void)awakeFromNib
{
  NSBundle *bundle;
  NSString *path;
  NSArray *identifiers;

  prefs = [[NSUserDefaults standardUserDefaults] retain];
  tableColumns = [[NSMutableDictionary alloc] init];

  recordsFile = [NSString stringWithString:@"~/Library/Preferences/AddressBookData.plist"];
  recordsFile = [[recordsFile stringByExpandingTildeInPath] retain];
  
  if ( [prefs arrayForKey:@"Addresses"] != nil )
    records = [[NSMutableArray alloc] initWithArray:[prefs arrayForKey:@"Addresses"]];
  else if ( [[NSFileManager defaultManager] fileExistsAtPath:recordsFile] == YES )
    records = [[NSMutableArray alloc] initWithContentsOfFile:recordsFile];
  else
    records = [[NSMutableArray alloc] init];
    
  // Load the identifiers array from the bundle resources
  bundle = [NSBundle mainBundle];
  path = [bundle pathForResource:@"Identifiers" ofType:@"plist"];
  identifiers = [[NSArray alloc] initWithContentsOfFile:path];

  [self addInitialColumnsForIdentifiers:identifiers];
}

This completes the first part of our strategy for the coding, onto part 2!

Second, the user defaults

This section concerns itself with using the foundation we have just built to initialize the table to display those columns that the user wants according to their preferences in the defaults database. The state which our application ended up in at the end of the previous column already had provisions for accessing users defaults through the NSUserDefaults instance prefs.

A simple way of storing which columns a user wants in their table is an array of column identifiers. Thus, we would have a key in the user's defaults database named "User Columns" and that key would correspond to a preference object that is an array of strings representing the column identifiers of the user's table columns. Let's make the variable that points to this array global -- declare it in the interface file with the other instance variables (you will see in a bit why we're making userColumns mutable):

NSMutableArray *userColumns;

The code in awakeFromNib to initialize userColumns looks like the following:

if ( [prefs arrayForKey:@"User Columns"] != nil ) {
  userColumns = [[NSMutableArray alloc] initWithArray:[prefs arrayForKey:@"User Columns"]];
  [self initializeTableWithColumns:userColumns];
} else {
  userColumns = [[NSMutableArray alloc] init];
}

The important thing to notice here is the if statement. What we're doing is first checking to see if an object exists for the key @"User Columns". If so, then we initialize userColumns to the array stored in user defaults, and invoke a new method initializeTableWithColumns: because, presumably, a user's preferred columns are different from the set we established in Interface Builder. Otherwise we just initialize userColumns to an empty mutable array, ready for use. Put another way, the table we set up in Interface Builder is the default table configuration, and it only gets changed if there is a user preference indicating so.

The second line calls a new method that we have yet to define called initializeTableWithColumns:, which takes an argument that is the array of identifiers from preferences for the columns that are to appear in the table. The meat of this step is contained in this method, so let's see what we can do.

The class NSTableView defines several methods that allow us to add, remove, and otherwise manipulate columns in the table. For example, we will use the method addTableColumn: to add a column to the table, and removeTableColumn to remove a column from the table. Both of these methods take an NSTableColumn: as their argument.

The most straightforward thing to do would be to remove the existing table columns from the table that we created in Interface Builder, and then add the columns indicated by the user preferences. We'll do this using two different enumerators, as follows:

- (void)initializeTableWithColumns:(NSArray *)identifiers
{
  NSEnumerator *e;
  id column, identifier;

  // clear out the existing columns in tableView
  e = [[tableView tableColumns] objectEnumerator];
  while ( (column = [e nextObject]) ) {
    [tableView removeTableColumn:column];
  }

  // add columns from the argument array
  e = [identifiers objectEnumerator];
  while ( (identifier = [e nextObject]) ) {
    column = [tableColumns objectForKey:identifier];
    [tableView addTableColumn:column];
  }
}

You can see how this method uses two enumerators. The first enumerator is for the array of table columns currently displayed in tableView. This array is obtained by sending tableView a tableColumns message, which returns the array of all NSTableColumn objects contained in the table. We immediately create an enumerator by sending an objectEnumerator message to this array, and proceed with our first while loop. This while loop simply enumerates the array of existing NSTableColumns, and removes them one by one, via the line [tableView removeTableColumn:column].

With that we have a table devoid of columns. Next we create a new enumerator, this time one to enumerate the argument array which contains the identifiers of the columns we want to add to the table view. In the while loop we get from tableColumns the actual NSTableColumn object whose key matches identifier, and add each NSTableColumn to tableView. And that's all there is! We've configured the table view to contain only those columns that the user wants.

A random aside

As a random aside, if you were in the business of making applications with multiple table views, you could modify initializeTableWithColumns: to work with multiple tables. You would have to change the name to initializeTable:withColumns: to add another argument, which is a table view. The actual implementation would change little:

- (void)initializeTable:(NSTableView *)aTableView withColumns:(NSArray *)identifiers
{
  NSEnumerator *e;
  id column, identifier;

  // clear out the existing columns in tableView
  e = [[aTableView tableColumns] objectEnumerator];
  while ( (column = [e nextObject]) ) {
    [aTableView removeTableColumn:column];
  }

  // add columns from the argument array
  e = [identifiers objectEnumerator];
  while ( (identifier = [e nextObject]) ) {
    column = [tableColumns objectForKey:identifier];
    [aTableView addTableColumn:column];
  }
}

Notice that all we did was replace all instances of tableView, our outlet, with the argument variable name aTableView. In awakeFromNib where we call this, we would have the line:

[self initializeTable:tableView withColumns:userColumns];

You can use this version if you like for this application, but it doesn't really matter as we have only one table.

Saving table column preferences

The next step in dealing with table column preferences is to implement a method to save the current table configuration to user defaults. We'll call this method saveTableColumnPrefs. This method is analogous in function to the method saveData that we implemented two columns ago. The implementation shown here will be very straightforward, saving only information about which columns were in the table and in what order they appeared. Later on we'll discuss possibilities for saving more information about the table configuration.

In this method we will create an enumerator of the table columns like we did above, clear out the array userTableColumns, and then repopulate userTableColumns with the identifiers of the columns currently contained in the table. So let's see this in code:

- (void)saveTableColumnPrefs
{
  id column;
  NSEnumerator *e = [[tableView tableColumns] objectEnumerator];
  
  [userColumns removeAllObjects];

  while ( (column = [e nextObject]) ) {
    [userColumns addObject:[column identifier]];
  }
  
  [prefs setObject:userColumns forKey:@"User Columns"];
}

Here is what we have: First we create the enumerator from the array of table columns returned by the tableColumns message to tableView. The next line clears out the userColumns array using removeAllObjects. Now we go into the while statement using the enumerator to get the next column in the array. Within the loop, we add to userColumns the string returned by an identifier message to column. At the end of this loop, we have an array of identifiers that preserves the order of table columns in the table, but before we leave the method in the last line, we tell prefs to make userColumns the object associated with the @"User Columns" key. This method will be used in a moment by the setColumn action method.

Setting the initial state of the check boxes

One other thing we need to do is set the initial states of the check boxes to match those columns put in the table by initializeTableWithColumns. We could easily do this in the initializeTableWithColumns: method, within the second enumeration, in the following manner:

- (void)initializeTableWithColumns:(NSArray *)identifiers
{
  NSEnumerator *e;
  id column, identifier;
  NSTableColumn *column;

  // clear out the existing columns in tableView
  e = [[tableView tableColumns] objectEnumerator];
  while ( (column = [e nextObject]) ) {
    [tableView removeTableColumn:column];
  }

  // add columns from the argument array
  e = [identifiers objectEnumerator];
  while ( (identifier = [e nextObject]) ) {
    column = [tableColumns objectForKey:identifier];
    [tableView addTableColumn:column];

    if ( [identifier isEqualToString:@"First Name"] )
      [firstNameCB setState:NSOnState];
    if ( [identifier isEqualToString:@"Last Name"] )
      [lastNameCB setState:NSOnState];
     // And so on with the other four check boxes.
  }
}

So you see, each time through the while loop, we check to see which column we're adding using NSString's isEqualToString:, and then set the state of the appropriate check box to NSOnState.

As always, however, we can vastly improve upon the way we handle this. One way we could do this is to create a dictionary with a set of keys that are the column identifiers, and the corresponding values being the six check box outlets. The following table shows what this dictionary will look like.

Key Value
@"First Name" firstNameCB
@"Last Name" lastNameCB
@"Email" emailCB
@"Home Phone" homePhoneCB
@"Work Phone" workPhoneCB
@"Mobile Phone" mobilePhoneCB

We'll call this dictionary checkBoxes, declaring it in Controller.h

NSDictionary *checkBoxes;

And then we can use this in initializeTableWithColumns: in the second enumeration as follows:

// add columns from the argument array
e = [identifiers objectEnumerator];
while ( (identifier = [e nextObject]) ) {
  column = [tableColumns objectForKey:identifier];
  [tableView addTableColumn:column];
  [[checkBoxes objectForKey:identifier] setState:NSOnState];
}

And you see how the series of if statements has been replaced by the one line. Before we can use this, however, we have to initialize checkBoxes in awakeFromNib. We will do this by using the NSDictionary method dictionaryWithObjects:forKeys:. This method takes two NSArray arguments, both of which must have the same number of elements. The first argument will be an array of checkBoxes, and the second argument will be the array of identifiers that we earlier obtained from the bundle resources. So this initialization requires that we first create an array of the check box outlets, and then use that in conjunction with the array identifiers to create a dictionary. Now order is important here -- the order of check boxes in the array must be the same as the order of strings in identifiers. Here is the code:

NSArray *checkBoxesArray;  // At the beginning of awakeFromNib,
                           //with the rest

checkBoxesArray = [NSArray arrayWithObjects:firstNameCB, lastNameCB, emailCB, homePhoneCB, workPhoneCB, mobilePhoneCB, nil];
checkBoxes = [[NSDictionary alloc] initWithObjects:checkBoxesArray forKeys:identifiers];

We're doing in four lines of code that which previously took twelve, and it is straightforward to include additional check boxes. So now we can access the check boxes by column identifier.

We're not yet done with this section. We still haven't considered the possibility that the check boxes might need to be set outside of initializeTableWithColumns:, specifically in the case that there was no preference for the key @"User Columns". Recall the code in awakeFromNib where we checked for this:

if ( [prefs arrayForKey:@"User Columns"] != nil ) {
  userColumns = [[NSMutableArray alloc] initWithArray:[prefs arrayForKey:@"User Columns"]];
  [self initializeTableWithColumns:userColumns];
} else {
  userColumns = [[NSMutableArray alloc] init];
}

Remember that when this particular preference is not present in the defaults database, this if statement will evaluate to "false", and we will simply initialize userColumns to an empty dictionary. Now that we're dealing with the initial states of the check boxes, we have another thing to worry about in the else block -- the default states of the buttons, to match the default configuration of the table. In our case, the default columns are First Name, Last Name, Email, and Home Phone, so we have to set the states of those check boxes to NSOnState:

if ( [prefs arrayForKey:@"User Columns"] != nil ) {
  userColumns = [[NSMutableArray alloc] initWithArray:[prefs arrayForKey:@"User Columns"]];
  [self initializeTableWithColumns:userColumns];
} else {
  userColumns = [[NSMutableArray alloc] init];
  [firstNameCB setState:NSOnState];
  [lastNameCB setState:NSOnState];
  [emailCB setState:NSOnState];
  [homePhoneCB setState:NSOnState];
}

Another possibility for this would have been to leave the code as it was, and simply build the interface so that these four buttons are on by default. The choice is yours.

At this point awakeFromNib looks like the following:

- (void)awakeFromNib
{
  NSBundle *bundle;
  NSString *path;
  NSArray *identifiers;
  NSArray *checkBoxesArray;
  
  prefs = [[NSUserDefaults standardUserDefaults] retain];
  tableColumns = [[NSMutableDictionary alloc] init];

  recordsFile = [NSString stringWithString:@"~/Library/Preferences/AddressBookData.plist"];
  recordsFile = [[recordsFile stringByExpandingTildeInPath] retain];
  
  if ( [prefs arrayForKey:@"Addresses"] != nil )
    records = [[NSMutableArray alloc] initWithArray:[prefs arrayForKey:@"Addresses"]];
  else if ( [[NSFileManager defaultManager] fileExistsAtPath:recordsFile] == YES )
    records = [[NSMutableArray alloc] initWithContentsOfFile:recordsFile];
  else
    records = [[NSMutableArray alloc] init];
    
  // Load the identifiers array from the bundle resources
  bundle = [NSBundle mainBundle];
  path = [bundle pathForResource:@"Identifiers" ofType:@"plist"];
  identifiers = [[NSArray alloc] initWithContentsOfFile:path];

  // create the check boxes dictionary to easily refer to controls in prefs window
  checkBoxesArray = [NSArray arrayWithObjects:firstNameCB, lastNameCB, emailCB, homePhoneCB, workPhoneCB, mobilePhoneCB, nil];
  checkBoxes = [NSDictionary dictionaryWithObjects:checkBoxesArray forKeys:identifiers];

  [self addInitialColumnsForIdentifiers:identifiers];
  
  if ( [prefs arrayForKey:@"User Columns"] != nil ) {
    userColumns = [[NSMutableArray alloc] initWithArray:[prefs arrayForKey:@"User Columns"]];
    [self initializeTableWithColumns:userColumns];
  } else {
    userColumns = [[NSMutableArray alloc] init];
    [firstNameCB setState:NSOnState];
    [lastNameCB setState:NSOnState];
    [emailCB setState:NSOnState];
    [homePhoneCB setState:NSOnState];
  }
}

Finally, implement 'setColumn'

With the first two parts of our strategy in place, we can now easily add and remove columns in response to the user changing the preferences, and have these changes preserved in the user's preferences. All of this is done in the method setColumn. Since we didn't create new Controller files from Interface Builder, we have to add setColumn by hand. Let's look for a moment at this method to gain a clearer idea of how we're going to proceed with this implementation.

The method definition should be added to Controller.m:

- (IBAction)setColumn:(id)sender
{
}

Recall how we connected six different buttons to this single action message. The idea is that whenever the user switches one of those preference check boxes, setColumn will be invoked, and we'll be able to identify which check box was changed given the information provided in the sender argument, and thus determine which table column to add or remove. The distinguishing characteristic of the sender object that allows us to do this is the title of the button, which we've conveniently made to be the same as all of the column identifiers, which are also the same as the keys in the tableColumns dictionary! We can get the title string by sending a title message to the sender, and then we can store this string in a local variable, identifier:

NSString *identifier = [sender title];

Once we have the name of the column we're working with, we need to determine whether we should remove the indicated column or add it to the table view; this depends on the state of the button after it has been clicked. Check boxes have two possible states by default: NSOnState and NSOffState. We can obtain the state of the sender check box by sending it a state message, whose return value we can compare to the constants NSOnState and NSOffState to determine our course of action. After putting all of these pieces together, here is the implementation of setColumn:

- (IBAction)setColumn:(id)sender
{
  NSString *identifier = [sender title];
  NSTableColumn *column = [tableColumns objectForKey:identifier];

  if ( [sender state] == NSOnState ) {
    [tableView addTableColumn:column];
    [self saveTableColumnPrefs];
  } else if ( [sender state] == NSOffState ) {
    [tableView removeTableColumn:column];
    [self saveTableColumnPrefs];
  }
}

So in the first line of this method we took the title of the sender button, and stored it in the NSString variable identifier. In the next line, we declare a temporary NSTableColumn variable, column, which we assign to the object returned from tableColumns that has the key-matching identifier.

Finally we check to see what the state of the check box is after the user has clicked it. If it is NSOnState, then we add the column to the tableView, and if it is NSOffState, we remove the column from the table view. The second if statement is actually a little redundant -- it could have just been an else statement because if it's not in NSOnState, then it must be NSOffState. However, I prefer the more explicit version -- just a personal preference.

Final thoughts

With that the basic framework of adding and removing columns both at start up based on saved preferences, and during runtime based on changing preferences is set. In the end, we have added five methods to our application, and we have extensively modified awakeFromNib. These five methods are:

The project folder for the final application in this column can be downloaded here.

As a final bit of housekeeping in this column, you should go into the dealloc method and add any necessary releases to the instance objects we've created and used.

Learning CocoaLearning Cocoa
By Apple Computer, Inc.
Table of Contents
Index
Sample Chapter
Full Description
Read Online -- Safari

This code will add and remove the columns, almost like we want it to. There are several things about this implementation that makes it incomplete in the sense that we're not going to get a good-looking table out of this. Primarily this is because the code does not take into account the widths of the columns If we fix this, then our setup could be quite good.

An obvious first enhancement to this code would be to store in user defaults more information than just a list of columns. We could instead store an array of dictionaries, where each dictionary has the potential to describe in detail how the user has each column configured.

For example, this dictionary could have a key named @"Identifier" that returns the identifier string of the column (which is the information contained in our original list). A second possible key that would greatly enhance the looks of our table is a key @"Width", which would allow us to store and restore the widths of the columns. The possibilities of further attributes to store go on. We could have a key-value pair to store how text should be aligned in the title or the column, or we could have another key-value pair that stores a Boolean, which might indicate whether a column should be editable. I leave the addition of these features to the reader. The source code for the next column will show my solution to this issue.

Speaking of the next column, we will be learning how to enhance our interface using that wonderful Mac OS X innovation -- sheets. See you next time!

Michael Beam is a software engineer in the energy industry specializing in seismic application development on Linux with C++ and Qt. He lives in Houston, Texas with his wife and son.


Read more Programming With Cocoa columns.

Return to the Mac DevCenter.

Copyright © 2009 O'Reilly Media, Inc.