Expandable/Collapsable Accordion UITableView

It’s been a while, I know. Most of the tutorials here are dating back to iOS 3/4. Oh boy…

I’ve recently run into yet another feature that required a UITableView to behave as an accordion. In other words, you have a list of items that can be tapped, revealing their associated sub items. This is great for creating UIs that require nested menu items, like category lists.

A common scenario would look like this:

All Collapsed

  • Fruits
  • Vegetables
  • Sweets
  • Nuts

Vegetables Expanded

  • Fruits
  • Vegetables
    • Cucumbers
    • Tomatos
    • Carrots
    • Goatsbeard
  • Sweets
  • Nuts

Accordion UITableView

The idea here is to have a list of parents, which have children. When a parent is expanded, its children are shown and can be interacted with. When another parent is tapped, the previous parent is first collapsed and then the new parent expands revealing its children. A classic accordion UI pattern.

There are several tricky points about writing this type of UI in iOS using UITableView:

  1. Determine if a cell is a child or a parent
  2. Determine proper insertion points for all scenarios:
    1. no cells are expanded when a parent cell is tapped
    2. currently expanded parent is tapped consecutively
    3. newly tapped cell is above the currently expanded parent
    4. newly tapped cell is below the currently expanded parent
  3. Insertion always happens after deletion. This is important to think about when collapsing cells at the same time you’re expanding them

To fill in our table view with data, I created two simple methods that simply generate a bunch of items and their corresponding sub items. Your data source will most likely be an API or a database.

- (NSArray *)topLevelItems {
    NSMutableArray *items = [NSMutableArray array];
 
    for (int i = 0; i < NUM_TOP_ITEMS; i++) {
        [items addObject:[NSString stringWithFormat:@"Item %d", i + 1]];
    }
 
    return items;
}
 
- (NSArray *)subItems {
    NSMutableArray *items = [NSMutableArray array];
    int numItems = arc4random() % NUM_SUBITEMS + 2;
 
    for (int i = 0; i < numItems; i++) {
        [items addObject:[NSString stringWithFormat:@"SubItem %d", i + 1]];
    }
 
    return items;
}

These can be called when you initialize your UITableView or whatever object you’re wrapping it in.

- (id)init {
    self = [super init];
 
    if (self) {
        topItems = [[NSArray alloc] initWithArray:[self topLevelItems]];
        subItems = [NSMutableArray new];
        currentExpandedIndex = -1;
 
        for (int i = 0; i < [topItems count]; i++) {
            [subItems addObject:[self subItems]];
        }
    }
    return self;
}

Now comes the meat. Two relevant UITableView delegate methods here are:

  1. – tableView:cellForRowAtIndexPath: (UITableViewDataSource)
  2. – tableView:didSelectRowAtIndexPath: (UITableViewDelegate)

#1 is there to display each cell as it dequeues and/or is created while scrolling through rows in your UITableView.

#2 is there to handle taps

1. – tableView:cellForRowAtIndexPath:

This method is responsible for 2 relevant things: a) determine if cell is child or parent b) fill the cell with content.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    static NSString *ParentCellIdentifier = @"ParentCell";
    static NSString *ChildCellIdentifier = @"ChildCell";
 
    BOOL isChild =
        currentExpandedIndex > -1
        && indexPath.row > currentExpandedIndex
        && indexPath.row < = currentExpandedIndex + [[subItems objectAtIndex:currentExpandedIndex] count];          UITableViewCell *cell;          if (isChild) {         cell = [tableView dequeueReusableCellWithIdentifier:ChildCellIdentifier];     }     else {         cell = [tableView dequeueReusableCellWithIdentifier:ParentCellIdentifier];     }               if (cell == nil) {         cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:ParentCellIdentifier] autorelease];     }        if (isChild) {         cell.detailTextLabel.text = [[subItems objectAtIndex:currentExpandedIndex] objectAtIndex:indexPath.row - currentExpandedIndex - 1];     }     else {         int topIndex = (currentExpandedIndex > -1 && indexPath.row > currentExpandedIndex)
            ? indexPath.row - [[subItems objectAtIndex:currentExpandedIndex] count]
            : indexPath.row;
 
        cell.textLabel.text = [topItems objectAtIndex:topIndex];
        cell.detailTextLabel.text = @"";
    }
 
    return cell;
}

There are only a couple things that are interesting here. First off, the boolean that determines whether or not the current cell is a child. Basically, a cell is a child when its index is larger than the currently selected parent and smaller than the current parent's index + the children offset (count of all children), inclusive.

Another mini calculation that needs to be done is to get contents of parent cells. Once the children are inserted, indexPath.row no longer directly corresponds to the array where our parents are stored. Therefore, we need to account for the children and subtract them from the current indexPath.row.

2. – tableView:didSelectRowAtIndexPath:

This is where the magic happens. When a row in our UITableView is tapped, this method is called and determines whether to expand, collapse or both (when a parent is already expanded).

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    BOOL isChild =
    currentExpandedIndex > -1
    && indexPath.row > currentExpandedIndex
    && indexPath.row < = currentExpandedIndex + [[subItems objectAtIndex:currentExpandedIndex] count];          if (isChild) {         NSLog(@"A child was tapped, do what you will with it");         return;     }     [self.tableView beginUpdates];     if (currentExpandedIndex == indexPath.row) {         [self collapseSubItemsAtIndex:currentExpandedIndex];         currentExpandedIndex = -1;     }     else {                  BOOL shouldCollapse = currentExpandedIndex > -1;
 
        if (shouldCollapse) {
            [self collapseSubItemsAtIndex:currentExpandedIndex];
        }
 
        currentExpandedIndex = (shouldCollapse && indexPath.row > currentExpandedIndex) ? indexPath.row - [[subItems objectAtIndex:currentExpandedIndex] count] : indexPath.row;
 
        [self expandItemAtIndex:currentExpandedIndex];
    }
 
    [self.tableView endUpdates];
 
}

Here we have the, now familiar, boolean isChild. It's the same one as in the previous method (so it could/should really be extracted into a helper method). There is, however, a new boolean, shouldCollapse. That is simply looking at currentExpandedIndex and checking whether or not a parent has already been expanded. Then, as mentioned before, there are 3 possible scenarios:

  1. Nothing is currently expanded; expand children under parent that's been tapped
  2. A parent has already been expanded; collapse it and expand new parent
  3. A child was tapped; perform whatever action appropriate to your app. This is the final destination.

One more note here. When you are inserting and deleting rows at the same time, the deletion is always performed first. Therefore, you must account for that detail in your insertion logic. In other words, the index at which you insert your children must be calculated with deletions in mind. It should be based on UITableView's state after the deletion has been completed. When you don't this properly, you'll end up with weird repeating or missing rows that don't respond to your taps as you'd expect and you might be tempted to call reloadData on the table, which really isn't necessary.

I also chose to scroll my table to the currently expanded parent. Hence the optional line, [self.tableView scrollToRowAtIndexPath...]. Feel free to remove it, if that's not what you want your UITableView do.

Here are the actual expanding and collapsing methods:

- (void)expandItemAtIndex:(int)index {
    NSMutableArray *indexPaths = [NSMutableArray new];
    NSArray *currentSubItems = [subItems objectAtIndex:index];
    int insertPos = index + 1;
    for (int i = 0; i < [currentSubItems count]; i++) {
        [indexPaths addObject:[NSIndexPath indexPathForRow:insertPos++ inSection:0]];
    }
    [self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade];
    [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:index inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:YES];
    [indexPaths release];
}
 
- (void)collapseSubItemsAtIndex:(int)index {
    NSMutableArray *indexPaths = [NSMutableArray new];
    for (int i = index + 1; i <= index + [[subItems objectAtIndex:index] count]; i++) {
        [indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
    }
    [self.tableView deleteRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade];
    [indexPaths release];
}

Conclusion

This fairly common UI element is really not that tricky to do once you get your insertions and deletions sorted out in your head. After that, it's UITableView as usual.

The code and a full project containing this functionality and everything mentioned in this tutorial can be found on GitHub.

https://github.com/vladinecko/accordion-uitableview


5 Responses to “Expandable/Collapsable Accordion UITableView”

Copyright © 2009 Vladimir Olexa | Copyright © 2009 Apple Inc. | Powered by WordPress