Tuesday, July 10, 2012

UITableView How-To: Part 3 - Multiple Sections

Part 1 | Part 2 | Part 4 | Part 5Part 6

 

In my experience so far, people seem to have a knack for making multi-section table views harder than they really are. The key to simplifying things is to prepare your data in such a way that pain is removed from your table delegate methods. If your delegate methods are nothing but switch/case statements or a ton of if/else if statements, then you've likely given yourself a pretty good headache.

 

Let's quickly revisit some key elements from Part 1. To determine the number of rows, we did this:

 

NSInteger rows = [[self contentsList] count];

 

...and to get the information to show in the cell, we did this:

 

NSString *contentForThisRow = [[self contentsList] objectAtIndex:[indexPath row]];

 

We count up everything we have, and then we use the row parameter to extract the specific piece of information. That's fine when we have one continuous list, but if you want a sectioned display, then you don't have one continuous list anymore. And this is true regardless of the display type.

 

There are two default display options for table views. Plain:

 

 

And grouped:

 

This is purely visual fluff. You can go back to the exercise in Part 1 if you want and flip it to grouped, and nothing else needs to change. The easiest way to manage this sectioned/grouped appearance is to group your data as well. The long list of 500 names that you have in your address book needs to be broken up into pieces.

 

How you actually get your data broken up is a programming exercise that I'm not going to go into here. There are lots of ways to do it, and the 'correct' approach will depend on your actual data and your specific needs. What I will show here are two different structures that you can use that simplify the delegate methods considerably. They are:

  • An array of arrays
  • A dictionary, and an array of keys

But first we need to introduce a delegate method that we haven't seen or used yet. In Part 1, we answered the question "how many rows in this section". The second half of that question is important, as we never indicated how many sections there are. This is yet another question the table view can ask, and it is:

 

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView

 

This is an optional method, as evidenced by the fact we didn't use it in the previous exercises. But clearly the table works without it, so what gives? As noted in the documentation, the default value is 1. So the tables we've seen so far actually were sectioned table views, just with only 1 section.

 

Just like we did for the number of rows, we should probably base the answer on a calculation. If you have a simple table that will only ever have 2 sections, then by all means go ahead and hard-code a 2. But if the number could change, it needs to be related to your data somehow. More on that in a moment.

 

For now, I'm going to take the code from Part 1, remove a couple of colors, and then set the number of sections to 3.

 

 

Notice that the rows repeat; I have 3 groups of the same thing over and over again. Why is that? Well, let's remember how we collected the data to display:

 

NSString *contentForThisRow = [[self contentsList] objectAtIndex:[indexPath row]];

 

Using the row alone, we pull data from the array. Why does it repeat? Because the row numbering starts over for each section. Section and row numbers look like this:

 

Section 0
Row 0
Row 1
Row 2
Section 1
Row 0
Row 1
Row 2
Section 2
Row 0
Row 1
Row 2

 

Three sections, so the table view asked for something to display in Row 0 three times. And that's exactly what we gave it: the first item in the array, 3 separate times.

 

This is why arranging the data in a particular way is important.

 

How do we find our way around in the table? Well, we've already seen this:

 

[indexPath row]

 

Now we also need to use this:

 

[indexPath section]

 

NSIndexPath actually does a lot more than this, but for most iPhone purposes it is used to describe a section and row location in a table view. In Part 1, we used the row parameter to select an item from the array. We are still going to do that, but we will now use the section parameter to decide which array.

 

Array of Arrays

 

As previously stated, the key is arranging your data in a way to facilitate a sectioned table view. We'll keep the same contentsList array that we had before, but we'll change the contents. Before, it contained only strings. Now, it will contain arrays. Those arrays will contain strings.

 

NSArray *firstSection = [NSArray arrayWithObjects:@"Red", @"Blue", nil];
NSArray *secondSection = [NSArray arrayWithObjects:@"Orange", @"Green", @"Purple", nil];
NSArray *thirdSection = [NSArray arrayWithObject:@"Yellow"];

NSMutableArray *array = [[NSMutableArray alloc] initWithObjects:firstSection, secondSection, thirdSection, nil];
[self setContentsList:array];
[array release], array = nil;

 

Same basic idea as before, but we've added some structure. We need to make adjustments to the delegate methods to account for this new structure. First, our new delegate method for number of sections:

 

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
NSInteger sections = [[self contentsList] count];

return sections;
}

 

This is the same calculation we started with, but now we are answering a different question. In Part 1, this calculation was for the number of rows. Now it is the number of sections. So far so good. Now we need to define the number of rows. You'll note I set up the arrays so that each one has a different number of objects. This is to help reinforce that these numbers probably shouldn't be hard-coded. You want everything to work whether your array has 5 objects or 500 objects.

 

In Part 1, the number of rows was the number of items in the main array. That is no longer the case. We must first identify which sub-array we're interested in, and then count that sub-array. There was a parameter we ignored before:

 

- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section

 

How many rows are in this section? We've told the table how many sections there will be, and the table will now call this method for each section, passing in the appropriate value. We'll use this to identify which array we want.

 

NSArray *sectionContents = [[self contentsList] objectAtIndex:section];

 

For the first section in the table, I want a reference to the first sub-array in the main array. Second array for the second section, and so on. The number of rows is then the count of this sub-array.

 

- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section
{
NSArray *sectionContents = [[self contentsList] objectAtIndex:section];
NSInteger rows = [sectionContents count];

return rows;
}

 

So it is the same idea as what we did before, we just have to count a different array each time. It's not horribly complicated, you just have to plan for it.

We use this same concept again to determine what the row contents are. The only difference is that we get to the section value through the indexPath parameter.

 

NSArray *sectionContents = [[self contentsList] objectAtIndex:[indexPath section]];

 

Now we do the same thing we did before, using the row parameter, but using this array instead of the main one.

 

- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSArray *sectionContents = [[self contentsList] objectAtIndex:[indexPath section]];
NSString *contentForThisRow = [sectionContents objectAtIndex:[indexPath row]];
...

 

After this, nothing is different than what was done in Part 1. Feed this string into the cell, and you should be good to go. If everything is wired up correctly, then you should see:

 

That's really all there is to it. Once you have this structure in place, you can add or remove as many colors as you want - to/from each section - and you don't have to mess with the delegate methods anymore. We added 1 delegate method, and 1 line of code each to two existing delegate methods (plus the extra stuff for setting up the data) vs. what we had in Part 1. Easy!

 

A dictionary, and an array of keys

 

Again there are many possible ways to structure your data, so I offer this next one merely as another example. But it is handy if you want even more data in your table view, specifically headers. If you look at the address book, you'll see letters for each group of people - A's, B's, etc. - and this data has to be set up somewhere, somehow.

 

Dictionaries store data using keys, typically strings. So you store something by name, and you retrieve something by name. Those names can be easily used as section headers. The problem is that dictionaries do not have order. There is no first object, second object, etc., and tables really like for things to be in order. So in addition to using the dictionary, we will continue to use an array to provide order.

 

Again, how you set up the data is pretty important. So let's start with the basics, we have a dictionary and an array:

 

@interface DictionaryViewController : UIViewController <UITableViewDelegate, UITableViewDataSource>
{
}

@property (nonatomic, retain) IBOutlet UITableView *mainTableView;
@property (nonatomic, retain) NSMutableArray *sectionKeys;
@property (nonatomic, retain) NSMutableDictionary *sectionContents;

 

I've kinda shown my hand here with the names. The dictionary will hold the contents of each section, and the array will hold the keys. The contents will be arrays, just like in the previous example. We're just going to access them in a different way. The data is prepared like so:

 

NSMutableArray *keys = [[NSMutableArray alloc] init];
NSMutableDictionary *contents = [[NSMutableDictionary alloc] init];

NSString *colorKey = @"Colors";
NSString *clothingKey = @"Clothing";
NSString *miscKey = @"Misc";

[contents setObject:[NSArray arrayWithObjects:@"Red", @"Blue", nil] forKey:colorKey];
[contents setObject:[NSArray arrayWithObjects:@"Pants", @"Shirt", @"Socks", nil] forKey:clothingKey];
[contents setObject:[NSArray arrayWithObjects:@"Wankle Rotary Engine", nil] forKey:miscKey];

[keys addObject:clothingKey];
[keys addObject:miscKey];
[keys addObject:colorKey];

[self setSectionKeys:keys];
[self setSectionContents:contents];

[keys release], keys = nil;
[contents release], contents = nil;

 

This should look reasonably similar to what we did before. We've added a dictionary, and you add data to a dictionary differently than you do an array, but otherwise it is the same idea. If you're paying attention to details (and as a programmer, you should be) then you'll notice that the order I added the keys is different than the order I added the arrays. I only did this to illustrate that the order of the dictionary doesn't matter, and the order of the array is what will be driving the table.

 

After this, the approach is pretty similar to what we did before. We need to tell the table how many sections:

 

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
NSInteger sections = [[self sectionKeys] count];

return sections;
}

 

Since the dictionary and the array have the same number of objects, I could have counted either one. But typically you'll want to use the array. If I'm testing various arrangements, I will often make the contents the same regardless, and observe differences by messing with the keys. Don't want colors today? Just don't add the key to the array, and nothing else needs to change.

 

Now we need to provide the number of rows. This is the same approach as last time, just going through the dictionary.

 

- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section
{
NSString *key = [[self sectionKeys] objectAtIndex:section];
NSArray *contents = [[self sectionContents] objectForKey:key];
NSInteger rows = [contents count];

return rows;
}

 

We grab the key using the section parameter, then grab the sub-array using that key. Same thing for the row contents:

 

- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *key = [[self sectionKeys] objectAtIndex:[indexPath section]];
NSArray *contents = [[self sectionContents] objectForKey:key];
NSString *contentForThisRow = [contents objectAtIndex:[indexPath row]];
...

 

At this point, we've essentially recreated the first example. But we went this way for a reason, and that reason is section headers. There is another delegate method:

- (NSString *)tableView:(UITableView *)tableView
titleForHeaderInSection:(NSInteger)section
{
NSString *key = [[self sectionKeys] objectAtIndex:section];

return key;
}

 

We've already seen how to grab the key, so we simply do that and use the key as the header.

 

Food For Thought

 

Usually when I see rookie attempts at multi-section tables, there is a lot of code like this:

 

if (section == 0)
{
...
}
else if (section == 1)
{
...
}
...

For a simple structure: Ok, it probably doesn't make a big difference
For a complex structure: No, just no

 

This is paving the way for a modification nightmare. We've only looked at cellForRow so far, but you're going to do the same thing in didSelectRow (we'll get to that later), too. If you decide to rearrange things, you've got to remember everywhere that it is supposed to change, and of course you'll forget, and things will go badly.

 

You'll notice that the code I've posted so far doesn't look anything like this. The logic is provided up front by the structure of the data, so it isn't necessary to complicate the delegate methods. All they have to do is select data, no other decisions are necessary.

 

But let's assume for a moment that there is indeed a reason to further customize in the delegate methods. Let's say that all of the text in the colors section should be red. No problem. But you still don't want to hard-code like this. What happens if tomorrow you decide that colors should be section 5? Then you have to change all of these statements.

 

There isn't really a good option for the array-of-arrays case, so this may be a good vote in favor of the dictionary approach. Rather than hard-coding the section number, I can be flexible according to the section key.

 

if ([key isEqualToString:@"Colors"])
{
// Make them red
}
else
{
// Make them black
}

 

Now you've got a condition that will trigger correctly regardless of the order of the data. And if today you are testing without colors, no problem. Drop the key, and this condition will never trigger.

 

Once you get comfortable with the basic concepts here, you may want to take a look at a post I made a long time ago: Taming Table Views. There, I show the structure of a custom model class that I use all of the time with sectioned table views. This would be used with the array-of-arrays approach, but instead -of-arrays, it would be -of-DisplaySections. It has a field for the header, a field for the letter index, a field for behind-the-scenes stuff if needed, and it has an array property for the contents. A couple months after I wrote that, I discovered that Apple has a similar class (actually a protocol) for working with CoreData stuff called NSFetchedResultsSectionInfo.

 

One last comment regarding searching. If all you do is take the code in these samples and apply them to your project from Part 2, then searching will not work. Keep in mind the way we've changed the structure. It used to be an array of strings. It is now an array of arrays of strings. So you will have to adapt the search routine to this new structure. It is along the same lines as what we've done above in the delegate methods, so you should be able to figure it out.

 

TableViewTutorial_Part3.zip

6 comments:

Anonymous said...

Very nice post, helped me a lot :)

Unknown said...

How can you integrate the search bar and search bar delegate with sections just like how Apple handles their Contacts.app?

BriTer Ideas said...

Combine this tutorial with Part 2.

Nichole said...

Hello, If I add say 4 more colors so that I have to scroll and reuse tableview cells, then this code in my cellForRowAtIndexPath

if ([key isEqualToString:@"Colors"])
{
cell.imageView.image = [[UIImage imageName:@"thumbnail.png"];
}
else
{
// Make them black
}

Will also load on the Pants cell in the Clothing section due to key still coming from [indexPath section] as seen here:
NSString *key = [[self sectionKeys] objectAtIndex:[indexPath section]];

How do I get around this problem?

Thank you for this tutorial and your help!

BriTer Ideas said...

Well, notice the comment that you still have there: "Make them black". That wording was deliberate. I could have said "Leave them black", but this would imply that they were already black, but that may not be what happens. So whatever you do in one case, you have to undo in the other case. You have to assume that the cell you are working with is 'wrong', and do whatever needs to be done to make it 'right'. You cannot assume you are dealing with a fresh, clean new cell. Here, you are setting an image in one case. This code is not being run again for your extra cells - add NSLog messages and verify for yourself - you are reusing a cell that previously had the image. So in the 'else' case, you need to undo this; you need to remove the image. cell.imageView.image = nil;

Jorge said...

Thank you, I was looking for an example of - (NSString *)tableView:(UITableView *)tableView
titleForHeaderInSection:(NSInteger)section

Post a Comment