macdevcenter.com
oreilly.comSafari Books Online.Conferences.

advertisement

AddThis Social Bookmark Button

Working with Tables: Writing an Address Book Application
Pages: 1, 2, 3, 4

Problems

Unfortunately version 2 has a subtle, but serious flaw that results from the simple fact that it is not safe to modify a mutable array while it is being enumerated. Consider the situation of a table with five records in it, and you want to delete the first and last records (you can do discontinuous row selection by command-clicking). So we do [tableView selectedRowEnumerator] and in return we get an enumerator of NSNumber objects for 0 and 4.



The first time we send nextObject to the enumerator, we get returned the NSNumber "0", and we end up removing the first member of the mutable array records. Before this, our array had five records with indexes 0 through 4. Now our array has four records with indexes 0 through 3 (the remaining records all get shifted down one slot to fill the space previously taken by the record we just deleted).

The problem is that the next time through the loop, nextObject will return the other NSNumber in the enumerator, 4. We then try to remove the object at index 4 in records, but the previous removal of the object at index 0 ensured that there will be no object at index 4. The result of this is that we get an index out-of-range error, and the last record remains untouched. The moral of the story is to not change the contents of an array while enumerating it, and we have to adopt a more indirect approach to removing multiple records.

So we have to come up with a way to remove from records the selected rows after the enumeration has taken place. One thing we could do during the enumeration is to build an array of records we wish to remove, but not remove them until the enumeration has finished. We can then use the NSMutableArray method, removeObjectsInArray:, to remove from the receiver any objects contained in the array we created during the enumeration. Here we have version 3:

-(IBAction)deleteRecord:(id)sender
{
  NSEnumerator *enumerator = [tableView selectedRowEnumerator];
  NSNumber *index;
  NSMutableArray *tempArray = [NSMutableArray array];
  id tempObject;

  while ( (index = [enumerator nextObject]) ) {
    tempObject = [records objectAtIndex:[index intValue]]; // No modification, no problem
    [tempArray addObject:tempObject]; // keep track of the record to delete in tempArray
  }

  [records removeObjectsInArray:tempArray]; // we're golden
  [tableView reloadData];
}

So we changed this by creating two variables: tempObject and tempArray. During the enumeration we did just what we said we would: retrieve the record from records corresponding to the selected row in each loop of the enumeration and store it in tempArray. After the enumeration finished, we have in tempArray all of the records from our data structure records that were selected for deletion in the table. With removeObjectsInArray, we remove from records all of the objects contained in tempArray (we're basically deleting the intersection of these two collections -- the common objects). Well! Fixed that problem!

Alert panels

Because deleting is not "undoable" in our implementation, it would probably be good idea to provide some sort of system feedback requesting user confirmation to a delete command so records aren't inadvertently deleted -- like with an alert dialog. The AppKit function, NSRunAlertPanel, allows us to do just that. This function takes the five arguments listed below.

  • Arg 1: a string representing the title of the alert panel.
  • Arg 2: message text to display in the alert panel
  • Arg 3: text to display in default button
  • Arg 4: text label for alternate button
  • Arg 5: text label for second alternate button

If you want to format your message text a la printf(), you can do so by adding the variables to display in the message as optional arguments at the end of the argument list.

The return value of this function is an integer that indicates which button was pressed. AppKit defines several constants related to alert panels: NSAlertDefaultReturn, NSAlertAlternateReturn, NSAlertOtherReturn, and NSAlertErrorReturn, whose values are 0, 1, 2, and 3 respectively. The values of these constants are the same as the integer values returned by NSRunAlertPanel, and can thus be used to determine which button was pushed with an equality test.

Let's change our code to incorporate an alert panel. While we're at it, let's have the system beep at us when the panel opens using the AppKit function NSBeep(). Here is what our final, version 4 of the deleteRecord code looks like:

-(IBAction)deleteRecord:(id)sender
{
    int status;
    NSEnumerator *enumerator;
    NSNumber *index;
    NSMutableArray *tempArray = [NSMutableDictionary array];
    id tempObject;

    if ( [tableView numberOfSelectedRows] == 0 )
        return;

    NSBeep();
    status = NSRunAlertPanel(@"Warning!", @"Are you sure you want to delete the selected record(s)?", @"OK", @"Cancel", nil);

    if ( status == NSAlertDefaultReturn ) {
        enumerator = [tableView selectedRowEnumerator];

        while ( (index = [enumerator nextObject]) ) {
            tempObject = [records objectAtIndex:[index intValue]];
            [tempArray addObject:tempObject];
        }

        [records removeObjectsInArray:tempArray];
        [tableView reloadData];
    }
}

The first thing we did after declaring are variables was to use NSTableView's numberOfSelectedRows message to check how many rows are selected. If there are zero rows selected, then there's really no point in trying to delete nothing, and we return from the method.

Next we issued a beep with NSBeep() and opened up an alert panel -- the return value we store in the variable status. If you don't want one of the buttons to be displayed in the alert panel, use nil as the argument value for that button's text. We did this above to eliminate the third button from the alert panel. You can see what this alert panel looks like in the image below.

Screen shot.
The alert panel.

Then we compared status to the constant NSAlertDefaultReturn and executed our previously written record deletion code.

We've now set up a nice, simple system of working with the data, but we haven't yet talked about how the table actually gets the data from our array to the interface. The next section will focus on this.

Setting up the data source

Now is the time that we set up the data source. Recall from above that in Interface Builder we made a connection between Controller and tableView indicating that Controller would act as tableView's data source. Whenever a table view receives a reloadData message, that's its signal to go to send messages to the data source (Controller) to retrieve the data to display. What are the messages the table view object sends to the dataSource object to retrieve data? Before I can answer that question I need to say a word about Objective-C protocols.

We can find those messages in the NSTableDataSource protocol, which is a part of AppKit. In Objective-C we already know of class interfaces, which publicly publish all of the messages that can be sent to that class. But messaging is a two-way street.

Often it is useful to know what messages a class sends so we can prepare potential receivers of those messages to respond to them appropriately and intelligently. Protocols do just that. A protocol makes public knowledge any methods that an instance of a class sends. A class is said to conform to a protocol if it implements the methods laid out in the protocol.

In our situation, tableView is sending out messages to its data source expecting data back. The protocol NSTableDataSource tells us the what the messages are, including their arguments and what return value the tableView is expecting. For a dataSource object to function properly, it must implement the minimum required methods for it to be a dataSource and conform to the protocol.

If we look at the NSTableDataSource protocol reference, we'll find that there are two methods that we must implement for a table to display a data source's data. These two methods are numberOfRowsInTableView:, and tableView:objectValueForTableColumn:row:.

The first of these two methods is simple. Basically, this method is the table view's way of asking the data source "How many records do you have?" and the data source responds kindly with an integer. Add this method to your Controller implementation file with the following code:

- (int)numberOfRowsInTableView:(NSTableView *)aTableView
{
  return [records count];
}

The argument aTableView refers to the table view that is asking for the information. This argument allows for one dataSource object to manage data for multiple table views. In our application, we only have one, so we don't have to make use of that argument. If you had more than one table in your application, however, you might use this argument in a conditional statement: If aTableView is Table 1, then return the number of records in Array 1, or if aTableView is Table 2, then return the number of records in Array 2.

Following NSTableDataSource's recipe, we see that the next method to implement is tableView:objectValueForTableColumn:row:. The code for our implementation looks like this:

-(id)tableView:(NSTableView *)aTableView
  objectValueForTableColumn:(NSTableColumn *)aTableColumn 
  row:(int)rowIndex 
{
 id theRecord, theValue;
    
 theRecord = [records objectAtIndex:rowIndex];
 theValue = [theRecord objectForKey:[aTableColumn identifier]];

 return theValue;
}

Again, we're not going to use the aTableView variable from the arguments. All that interests us here is rowIndex and aTableColumn. Whenever we send tableView a reloadData message, the table view in turn scans through the columns and rows, sending this message to get the contents for each cell in the table. By attaching to the message row and column information, we're able to zero in on the correct data value within our two-dimensional data structure.

What we did first in our implementation was declare two generic object variables. By declaring these variables as type id, we eliminate the risk of trying to assign a variable to an object that it is not typed to. In the second line we assigned to theRecord the dictionary object we have stored at the rowIndex index of records.

Then, we use this dictionary to obtain the appropriate object to be displayed in aTableColumn. How do we know which is the right object? Well, fortunately we made the column identifiers and dictionary keys for each data field all the same, so all we have to do is send the dictionary object, theRecord, an objectForKey: message with the identifier string of the column as the key argument. This string is obtained by sending aTableColumn an identifier message.

For example, tableView might send the message to get data from the data source with row 0 and aTableColumn in the argument list. Our code will access the first element of records (corresponding to the first row of the table) and point the variable theRecord to that. We then get the column identifier string we set in Interface Builder from aTableColumn, which might be "First Name" and use that as a key to access the first name data contained in theRecord. The returned object from objectForKey: is then stored in the variable theValue which is then returned to tableView.

We finally have the primary pieces of code in place to make this fly, so compile it, hope there aren't any typos or anything, and run it. You should be able to add, insert, and delete records, and they should all appear in the table view.

As a final exercise, the NSTableDataSource protocol mentions a method that we can implement that allows us to change the data of a record directly in the table. If you double-click on a cell, you will be able to edit it, but the changes won't be saved unless this method is implemented in the dataSource object. Go ahead and give it a go.

And, the end

So that's how we implement a table in Cocoa. It seems like a lengthy process, but its really straightforward once you realize how the different pieces and objects interact. We had in our application the Controller object, which served the noble purpose of adding or subtracting records from our data structure in response to user actions, as well as letting the table know when it should update its contents.

We also had a simple data structure, which was nothing more than a collection of standard Cocoa mutable dictionaries penned up in a standard Cocoa mutable array. Finally, our table view had a dataSource outlet that told the table where to send messages to get data to display.

In our case, we made Controller our dataSource. The only requirement an object needed to work as a dataSource was that it minimally conform to the NSTableDataSource protocol. If you want to learn more about protocols in general, check it out in Object-Oriented Programming and The Objective-C Language (by Apple, located online for those of you who still haven't read this book). If you're having trouble, you can download the project folder for AddressBook.

This tiny application has quite a bit of functionality for the amount of work we did, but it's hardly complete. For one, we have no way to save the data between different sessions. Notice that we didn't create this application as a document-based application, which we certainly could have done, but I want to show you a different way we can save data (think about how you might save and load the data for this address book if we had gone the doc-based app route. Is there anyway we can make this transparent to the user so that the same set of data is opened at launch time by default, and saved each time we modify it?). So the plan for the next column is to implement data saving, as well as let our application remember other things about itself before running (such as window size). Until then, happy coding!

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.