Thursday, September 10, 2009

Taming Table Views

This is the table view from one of the primary screens in SlickShopper.



It may not look like much, but there is actually a lot going on here. The key aspects I want to talk about now are the sections headers and the index. More specifically, what the presence of those items means as far as preparing the data that is to be shown in the table.

Naturally I have a complete list - an array - of all grocery items. However, as soon as I made the decision that that I wanted section headers, I could no longer use that list directly. Well, that may not be technically true, but from a "reasonable effort" standpoint, true enough. In order to display section headers, the table view data has to be broken up into groups, with each group corresponding to a section in the table.

The number of sections is defined using the table view data source method:
– numberOfSectionsInTableView:

I've got "selected" and "not selected", so all I need to return here is 2. So far so good. I need 2 arrays, one for selected, one for not selected, and I need to run through my master list and add each grocery item to the appropriate array. The remaining tableview delegate methods are straightforward; needing only to ping each array for information. Easy.

...until I decided that having the index on the side would be a really useful thing. Why does that matter? Well, the first clue is in the method name that provides the index:
- sectionIndexTitlesForTableView:

Index titles for a section. Well, at the moment I only have 2 sections, but I'm going to want potentially almost 30 items to appear in the index. Although you don't technically have to have a 1:1 section:index title ratio, it's a lot easier if you do. When tapping the letter 'G' in the index, you want to navigate to items starting with the letter G, and that is most easily accomplished by grouping those items together, and having that be a section in the table.

So now I'm looking at a whole mess of arrays: one for items starting with A, one for B, and so on. Not a problem, I can add each of those separate arrays to one main array, and go from there. Oh, but if no items start with a particular letter - say, Q - I don't want to show the letter in the index. My array contains 24 arrays, and I'm looking at the 6th one, so which letter should appear in the index? F? What if there were no E's... it could be G. Ugh. Now I need another array to keep track of index letters. I suppose I could grab grocery items and extract their first letter on the fly, but that has some issues (ex: I want to display "#" instead of "1", "2", etc). I've already got my array of arrays to provide the contents, now I have another array for the letters, but I think we're almo... aw, crap... The section headers! The screen shot above is only showing 2 headers, but there are indeed many sections involved. And I have a different view of this same information that uses many headers. Now I need some means of keeping track of the headers - maybe another array? I've got dozens of arrays now, each keeping track of different-but-related pieces of information... stop the insanity!

After spending way too much effort doing exactly that, I finally hit upon what I consider to be a better way: a custom container object. Something that can correlate to the sections in a table, and contain the necessary pieces of information all in one place: the section contents, the section header, the index letter, and for bonus points some handy methods for messing with all of that stuff. So, I now present what I call a DisplaySection:

//  DisplaySection.h
// SlickShopper
//
// Created by Brian Slick on 4/5/09.
// Copyright 2009 BriTer Ideas LLC. All rights reserved.
#import Foundation/Foundation.h (Yeah, I know)
@interface DisplaySection : NSObject
{
NSString *myName;
NSString *mySectionHeader;
NSString *mySectionIndexLetter;
NSMutableArray *myContents;
}
// A few sample methods...
- (void)addGroceryItem:(GroceryItem *)item;
- (NSUInteger)countOfGroceryItems;
- (GroceryItem *)groceryItemAtIndex:(NSUInteger)index;
@end


There are a lot of ways to tailor this to individual liking. If you like _instanceVariable names, do that. If you want to make this generic with things like addObject, or countOfContents, do that. If you want to access the properties directly without going through wordy method names, do that. Those are implementation details that do not affect the core concept.

The point here is that everything I want to know about my section is here in one place: contents, section header, section index letter. On top of that, I found it necessary to add an extra property for behind-the-scenes purposes.

How does this work? Well, I don't want to give away all of the family jewels, but in essence most of the sorting work that I described before still has to happen. I still have to iterate through my master list of grocery items. I still have to have a place to put each item, so that means creating a bunch of these DisplaySections, and filling them with appropriate content. And I want these to be presented nicely for the table, so that means putting a bunch of these DisplaySections into an array.

So what's really different? For one thing, I've moved all appropriate logic into the sorting routine. Take the section header, for example. Before, I was defining that in the table delegate, making decisions based on the index path. If section is 0, header is this, else if section is 1, header is that, and so on. Those decisions still have to be made, but I no longer do that in the delegate methods. As I iterate through the main array, I make those decisions at that time, and react accordingly. This is my first DisplaySection, so the header property should be set to this. This DisplaySection will contain all items starting with H, so set the index letter accordingly. All of the logic is in one place. If I want to change behaviors, that's where I go.

A side effect of this is that it simplifies the delegate methods tremendously. Here is how the header gets returned:

- (NSString *)tableView:(UITableView *)tableView
titleForHeaderInSection:(NSInteger)section
{
return [[[DataController sharedDataController] displaySectionAtIndex:section] sectionHeader];
}


(I'm using a singleton object for the data controller... go here for more info)

I can assure you that this was more than one line of code previously. Now I ask the sorted array for the appropriate DisplaySection, and I grab the needed piece of information. The logic is removed from the delegate, and it merely does what it is supposed to do - relay information from the model to the view.

For the index letters, I elected to spin my own array in the delegate method. I really wish Apple had gone with more of a:
- (NSString *)tableView:indexTitleForSection:

approach, but they didn't. So I just loop over each DisplaySection, grab the index letter property, and build a new array in:
- (NSArray *)sectionIndexTitlesForTableView:

There is probably a better way, but this covers my needs. I'm pretty sure that this method is only hit once during a reloadData, so I don't consider it much of a performance hit.

How do I handle the case of nothing-starts-with-that-letter? That's part of the sorting routine. I actually go ahead and build the maximum possible number of DisplaySections up front, and add them to the array. Then I loop through the grocery items, and add them to the appropriate DisplaySection. After that is done, I loop back through the DisplaySections, and remove any that are empty.

Once I had this technique down, it was a lot easier for me to go back and add the index to several more of my views. So I'm very happy with this approach, but I'm always interested in suggestions. Have a better way?

No comments: