Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / iPhone

Collapsable Table View for iOS

4.93/5 (24 votes)
27 Oct 2013CPOL16 min read 365.4K   6.8K  
A set of source files that extends the UITableView so that it has sections that can be collapsed and expanded.

Introduction 

I was once working on an iPhone application that shows a large number of inputs, grouped into various categories, in a UITableView. To change the value of one of the inputs, the user presses the corresponding row in the table view and alters the value in a separate screen that appears. The table view had a section for each category and each section contained a table cell (row) for each input.

The problem was that the number of inputs became very, very large so that it didn't give the user a very good overview. It was even tedious to scroll from the top of the table to the bottom. 

We decided that the user should be able to collapse and expand the sections (categories) of the table by simply pressing the header of the section. We required that the code that achieves this should be reusable and that it requires the least possible number of changes to the existing code. 

The screenshots below show what the table view, with its collapsible sections, looks like: 

Up to iOS 6iOS 7 +
Image 1    collapsabletableview/collapsable_table_view_iOS7.png

Implementation

I figured that the best way to achieve the objectives mentioned above was to create a sub-class of the UITableView class, named CollapsableTableView. This ensures that the code is reusable. If done right, no changes would have to be made to the delegate or data source of the UITableView - they would handle the table view like a regular UITableView. The only necessary change would be to change the class of the UITableView in the xib file to this new sub-class. In order to ensure that the client can use the table view like a regular UITableView, we must try to allow the table view to be manipulated entirely through the interface of the UITableView class, including the UITableViewDelegate, and the UITableViewDataSource protocols.

The collapsible table view must somehow keep track of which sections are collapsed (contracted) and which of them are expanded. Perhaps the most apparent way to do this would be to maintain a set of indices of sections that are expanded, or a boolean array where the value of each index indicates if the corresponding section is expanded or not. However, if we assume that the client of the table view can add and remove sections (which was the case in our scenario), the indices of sections will not remain fixed, so working with the indices would be troublesome at best. We must therefore find a different identifier for sections. We could use the header text of sections for this purpose. Of course, this assumes that the header text of a section uniquely identifies that section and that its header text remains constant, but given the constraint of having to stick to the interface of the UITableView class, this is probably the best we can do. This also assumes that the client implements the tableView:titleForHeaderInSection: selector of the UITableViewDelegate protocol for all of the table cells. For our project, this was the case. In the Using the code section, we explain how our class also supports clients that implement the tableView:viewForHeaderInSection: selector.

For easier management of the header views, we create a UIViewController class, named CollapsableTableViewHeaderViewController. For this class, there are two xibs. One xib is used for a table with a plain layout, and the other one is used for a table with a grouped layout. This class contains IB outlets for all the labels in the view that can be manipulated. It stores a boolean value indicating if the section is collapsed or not. This view controller class also ensures that its view notifies us when the user taps it, so that the CollapsableTableView can take the necessary action. 

Here is the contents of the .h file of CollapsableTableViewHeaderViewController:

C++
#import <UIKit/UIKit.h>
#import "TapDelegate.h"
#import "CollapsableTableViewTapRecognizer.h"

@interface CollapsableTableViewHeaderViewController : UIViewController 
{
    IBOutlet UILabel *collapsedIndicatorLabel,*titleLabel,*detailLabel;

    CollapsableTableViewTapRecognizer* tapRecognizer;

    BOOL viewWasSet;
    id<TapDelegate> tapDelegate;

    NSString* fullTitle;
    BOOL isCollapsed;
}

@property (nonatomic, retain) NSString* fullTitle;
@property (nonatomic, readonly) UILabel* titleLabel;
@property (nonatomic, retain) NSString* titleText;
@property (nonatomic, readonly) UILabel* detailLabel;
@property (nonatomic, retain) NSString* detailText;
@property (nonatomic, assign) id<TapDelegate> tapDelegate;
@property (nonatomic, assign) BOOL isCollapsed;

@end

collapsedIndicatorLabel is a small label displaying a '–' or a '+' depending on whether the section is collapsed or not. When the value of isCollapsed is changed, the text of the collapsedIndicatorLabel is set to "–" or "+", accordingly. titleLabel is the label containing the text of the header and detailLabel shows optional detail text to the right of the title.

Here follows the definition of the TapDelegate protocol:

C++
#import <UIKit/UIKit.h>

@protocol TapDelegate

- (void) view:(UIView*) view tappedWithIdentifier:(NSString*) identifier;

@end

The view:tappedWithIdentifier: selector is called when a header view is tapped and CollapsableTableView implements the TapDelegate protocol so that it can collapse or expand the corresponding header in response to this. When the selector is called, it will be called with the header view for the view parameter and the title string of the header for the identifier parameter, so that the CollapsableTableView can do a look-up to determine whether the header is currently collapsed or not, and what its current section index is.

In the first published implementation of this project, this selector was called by the CollapsableTableViewHeaderViewController of the corresponding header view. That worked because in that version, the CollapsableTableView stored (and thus retained) the CollapsableTableViewHeaderViewControllers of all of its sections. However, to make the implementation more memory-efficient - especially for tables with many sections - the CollapsableTableView was changed so that it no longer does this. As a result, it turns out that the CollapsableTableViewHeaderViewController of a header view is released from memory shortly after the header view appears in the table (the header UIView still remains in memory as long as it's visible in the table). This means that when the header view is tapped there will probably be no CollapsableTableViewHeaderViewController to call the TapDelegate selector.

Before we look for a solution to this problem, let's see how the tap of a header view was previously detected and handled in CollapsableTableViewHeaderViewController.m.

C++
- (void) setView:(UIView*) newView
{
    if (viewWasSet)
    {
        [self.view removeGestureRecognizer:tapRecognizer];
        [tapRecognizer release];
    }
    [super setView:newView];
    tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self 
        action:@selector(headerTapped)];
    [self.view addGestureRecognizer:tapRecognizer];
    viewWasSet = YES;
}

- (void) headerTapped
{
    [tapDelegate viewTapped:self.view ofViewController:self];
}

So we had overridden the setView: method of the UIViewController class in order to add a UITapGestureRecognizer to the UIView that is assigned to the CollapsableTableViewHeaderViewController. This UITapGestureRecognizer is configured to call a method of the CollapsableTableViewHeaderViewController whenever the header view is tapped. In the new code, this technique no longer works since the CollapsableTableViewHeaderViewController will often have been deallocated by the time the user taps a header.

The one solution to this problem that is probably the most obvious is to configure the UITapGestureRecognizer to call a selector in an object that will not have been deallocated when the user taps the header view. Some choices for this object are:

  • CollapsableTableView
  • UIView
  • UITapGestureRecognizer

The second choice will not work, since we don't have any control over where the UIView that is passed into the setView: method comes from (that is, we cannot sub-class UIView in order to add an extra method to it; perhaps we could wrap the passed-in UIView object in an instance of a sub-class of UIView of our own, but let's not go there!). Adding a method to CollapsableTableView is an option, although adding a method with no parameters will not do, since the CollapsableTableView would not know which header has been tapped. However, in the documentation of UITapGestureRecognizer, we see that the alternative selector-type is a selector that takes the UITapGestureRecognizer object as a parameter. However, we would have to sub-class UITapGestureRecognizer in order to add a property that stores the title string of the header. So if we must sub-class UITapGestureRecognizer, it would probably be more elegant to go with the third option and configure the UITapGestureRecognizer to call a selector within itself. This is the approach taken in the implementation: we use a sub-class of UITapGestureRecognizer, which we call CollapsableTableViewTapRecognizer, and define it like this:

C++
#import <Foundation/Foundation.h>
#import "TapDelegate.h"

@interface CollapsableTableViewTapRecognizer : UITapGestureRecognizer
{
    id<TapDelegate> tapDelegate;
    
    NSString* fullTitle;
    UIView* tappedView;
}

@property (nonatomic, assign) id<TapDelegate> tapDelegate;
@property (nonatomic, retain) NSString* fullTitle;
@property (nonatomic, assign) UIView* tappedView;

- (id) initWithTitle:(NSString*) theFullTitle andTappedView:(UIView*) 
       theTappedView andTapDelegate:(id<TapDelegate>) theTapDelegate;

@end  

Within the initWithTitle:andTappedView:andTapDelegate: method, the CollapsableTableViewTapRecognizer object is configured to call the private method headerTapped when the view is tapped.

C++
- (void) headerTapped
{
    [tapDelegate view:tappedView tappedWithIdentifier:fullTitle];
} 

Let's return to CollapsableTableView now. When it gets a header title of a section from the client, it needs to be able to do a look-up to see if the header is collapsed, and what the section index of the header is. For this, we maintain two separate NSMutableDictionary objects: one that maps a header title to a boolean value indicating if the header is collapsed, and one that maps a header title to an integer giving the section index of the header. It also comes in handy to have a dictionary that we can use to look up the header title of the section at a specified index (of course, this dictionary will have to be updated whenever the client adds or removes a section from the table).

So, how will the CollapsableTableView actually collapse and expand sections? Well, a collapsed section will simply have 0 rows, so even though the client will return the normal number of rows for the section, CollapsableTableView will return 0 for the number of rows of a collapsed section, or the number returned by the client for an expanded section. This suggests that CollapsableTableView needs to intercept the calls to the tableView:numberOfRowsInSection: method. It must also return a view of a CollapsableTableViewHeaderViewController for each section, so it must also intercept calls to the tableView:viewForHeaderInSection: method. So in order for CollapsableTableView to be able to respond to both these selectors, it must implement the UITableViewDelegate and the UITableViewDataSource protocols and at run-time set its delegate and data source properties to... itself! Many calls to the selectors of these protocols, however, must be forwarded to the client, so CollapsableTableView stores references for the real delegate and data source so that they can be consulted for these cases.

C++
- (void) setDelegate:(id <UITableViewDelegate>) newDelegate
{
    [super setDelegate:self];
    realDelegate = newDelegate;
}

- (void) setDataSource:(id <UITableViewDataSource>) newDataSource
{
    [super setDataSource:self];
    realDataSource = newDataSource;
}

Here is the interface file of CollapsableTableView:

C++
#import <Foundation/Foundation.h>
#import "TapDelegate.h"

#define COLLAPSED_INDICATOR_LABEL_TAG 36
#define BUSY_INDICATOR_TAG 37
 

@interface CollapsableTableView : 
   UITableView <UITableViewDelegate,UITableViewDataSource,TapDelegate>
{
    id<UITableViewDelegate> realDelegate;
    id<UITableViewDataSource> realDataSource;
    id<CollapsableTableViewDelegate> collapsableTableViewDelegate;
    
    ...
}

@property (nonatomic,assign) id<CollapsableTableViewDelegate> collapsableTableViewDelegate;
@property (nonatomic,retain) NSString* collapsedIndicator;
@property (nonatomic,retain) NSString* expandedIndicator;
@property (nonatomic,assign) BOOL showBusyIndicator;
@property (nonatomic,assign) BOOL sectionsInitiallyCollapsed;
@property (nonatomic,readonly) NSDictionary* headerTitleToIsCollapsedMap;

- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*) headerTitle;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*) 
                               headerTitle andView:(UIView*) headerView;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*) 
         headerTitle withRowAnimation:(UITableViewRowAnimation) rowAnimation;
- (void) setIsCollapsed:(BOOL) isCollapsed forHeaderWithTitle:(NSString*) 
         headerTitle andView:(UIView*) headerView 
         withRowAnimation:(UITableViewRowAnimation) rowAnimation;

@end   

The implementation of CollapsableTableView pretty much follows from the discussion up to this point. The next paragraphs briefly explain the purposes of the public properties and methods of the class.

The collapsableTableViewDelegate property can be set to an object that implements the CollapsableTableViewDelegate property, so that object will be notified whenever a section will collapse or expand, and when it finished collapsing or expanding.

The default collapsed and expanded indicators (that default to a '+' and a '–' respectively) can be set using the collapsedIndicator and expandedIndicator properties.
The showBusyIndicator property has a default value of YES, and if set, causes an activity indicator view (spinner) (located in the header view as the subview having the tag number defined by BUSY_INDICATOR_TAG) to animate on a header view when collapsing or expanding its section takes longer than 0.5 seconds.
The sectionsInitiallyCollapsed property has a default value of NO, and controls if new sections will be initially be collapsed or not. 

The headerTitleToIsCollapsedMap property supplies an NSDictionary that maps a header's title string to an NSNumber object that contains a boolean indicating if the header is collapsed.

The setIsCollapsed:forHeaderWithTitle:... methods are to be used to programmatically collapse or expand sections. If the client has a reference to the UIView of the corresponding header, it can call one of the methods containing andView: with that UIView as parameter. If any one of the two other methods are called, the corresponding section (and header view) will have to be reloaded and the animation will sometimes be less nice than when one of the ...andView: methods are used. 

Using the Code

The source files that need to be added to an Xcode project in order to use the CollapsableTableView class are those in the CollapsableTableView folder in the zip file (Download source code - 101.9 KB). 

CollapsableTableView can be used exactly like a regular UITableView, as long as the client implements the tableView:titleForHeaderInSection: selector (opposed to tableView:viewForHeaderInSection:) for all the table cells. The only necessary change to be made is to change the class of the UITableView in the xib to CollapsableTableView. To do this, open the xib file, select the UITableView, open the Identity Inspector and type "CollapsableTableView" next to the Class field.

The implementation of CollapsableTableView does also allow the use of tableView:viewForHeaderInSection:, but here it doesn't have access to the title string of the header, which it normally uses as the identifier of the header. Instead, it uses the string "Tag %i", where %i is the value of the tag property of the view that is returned (if tag is 0, but the section index is not 0, this number defaults to the section index in CollapsableTableView). This means that if the client returns views (instead of header text strings) for some cells, and if it can add and remove sections, it must assign a unique tag number to the view corresponding to each section.

If the client returns views for some cells, those views can contain a label that indicates if the header is collapsed. Simply set the tag property of that label to the value COLLAPSED_INDICATOR_LABEL_TAG as defined in CollapsableTableView.h. The CollapsableTableView will then set the text of that label to '+' or '–' whenever the section is collapsed or expanded  (unless the collapsedIndicator or expandedIndicator properties have been set to different strings).

The client of the CollapsableTableView can be unaware of the fact that it is not working with a regular UITableView, but if it does know that the UITableView is a CollapsableTableView, it can cast the object to the latter type and use the headerTitleToIsCollapsedMap property to determine which sections are collapsed and the setIsCollapsed:forHeaderWithTitle: methods to programmatically collapse or expand sections.  

As was mentioned in the Implementation section, CollapsableTableView also allows for detail-text to be displayed to the right of the title of a header. To make use of this feature, in tableView:titleForHeaderInSection:, return a string of the form "Header Text|Detail Text".

History

  • 2011/08/13
    • Initial version
  • 2011/10/29
    • With the previous version, in iOS 5, the headers all had a height of 0. The tableView:heightForHeaderInSection: selector now finds the appropriate header view and asks it for its height directly, which fixes the problem.
    • Support for multiple line headers was added. If the numberOfLines property of the label in the appropriate header view controller xib is set to 0, and the header's text doesn't fit into one line, the label will break the text up in as many lines as necessary, and the header view controller will set the height of the label and the header view so that all the lines will fit (this is done in the setTitleText: selector in CollapsableTableViewHeaderViewController.m). The number of lines of a header can be limited by setting the label's numberOfLines property to the maximum number of lines.
    • 1st.osama pointed out that the setIsCollapsed:forHeaderWithTitle: selector did not have an effect until after the entire section of the corresponding header had been reloaded explicitly. This has been fixed.
    • When the last section is collapsed and it is expanded, the table view scrolls down to at most the fifth row of that section so that the user can see that some rows have appeared.
  • 2011/11/05
    • Fixed bug that occurred with iOS 4 which caused header titles of a plain-style table view to disappear.
  • 2011/11/27
    • CollapsableTableView was changed so that it no longer stores all the CollapsableTableViewHeaderViewController objects of its sections. The purpose of this change was to make the implementation more memory efficient, especially for tables with many sections. This was quite a dramatic change, but the relevant parts in the article have been updated.
    • The getHeaderTitleToIsCollapsedMap method of CollapsableTableView was replaced by the read-only property headerTitleToIsCollapsedMap.
    • Custom header views can now contain a label with the magic tag value of 36 (as defined by the constant COLLAPSED_INDICATOR_LABEL_TAG in CollapsableTableView.h) whose text will be updated to '+' or '–' whenever the corresponding header is collapsed or expanded.
  • 2012/01/26
    • Per alaska22's request, in addition to the initWithCoder: method, CollapsableTableView now also overrides the init, initWithFrame:, and initWithFrame:style: methods to do the necessary initializations. This is so that the CollapsableTableView can be constructed programmatically as well, as opposed to having it constructed by a xib.
  • 2012/02/12
    • As suggested by magikcm in the comments, I've implemented an optimization for row insertions when a section is expanded. I did this by implementing the "dishonest proxy data source" strategy as explained in the first answer to the question of this post.
  • 2012/08/10
    • Previously, when the table view needed to know the height of a header view, the collapsible table view would actually create that header in order to determine and return its height. This caused large delays whenever the table view needed to recompute its total height, since the heights of all the headers are queried during this process. This happened, for example, when expanding or collapsing a section. The delay was especially noticeable with a large number of sections.

      The collapsible table view now caches the heights of all header views. This virtually eliminates the excessive delay that occurred when collapsing or expanding sections.

    • Aesthetic improvement: the collapsed/expanded indicator of the header views of expanded sections now show a longer dash character instead of the regular, short dash character.
  • 2012/11/14
    • When a section is empty, its collapsed/expanded indicator is not shown.
    • The collapsed- and expanded indicators can now be set to custom strings through the collapsedIndicator and expandedIndicator properties of CollapsableTableView.
  • 2012/12/23
    • If the client provides both views and titles for headers (by implementing tableView:viewForHeaderInSection: and tableView:titleForHeaderInSection:), then views are preferred whenever the view returned for the section is not nil.
      Footers are handled similarly. This behaviour corresponds to the way that UITableView is implemented.
    • Added support for section footers.
    • Fixed a crash that occurred when the last section contained no rows and it is tapped.
    • When a section takes more than 0.5 seconds to collapse or expand, an activity indicator appears on the header view. (Whew! User interface programming using multiple threads is tricky!)
      This feature can be switched on or off by setting the showBusyIndicator property (by default it is on). To enable this behaviour in a custom header view, add a UIActivityIndicatorView to the header view with a tag value of BUSY_INDICATOR_TAG (defined in CollapsableTableView.h as 37).
    • A CollapsableTableViewDelegate can be assigned to the CollapsableTableView, so that the delegate can be notified whenever a section will begin to collapse or expand, or when it had finished collapsing or expanding.
    • The new property sectionsInitiallyCollapsed of CollapsableTableView controls if new sections are initially collapsed or not. The default value is NO.
  • 2013/01/18
    • Fixed crash that occurred when scrollToRowAtIndexPath:…, or selectRowAtIndexPath:…, or deselectRowAtIndexPath:… was called on a row inside a collapsed section.
    • Fixed the methods mentioned above so that when they are called with animated=YES, the effect will be immediate (synchronous).
    • Fixed a crash that occurred occasionally when collapsing and expanding sections.
    • Implemented remove-rows optimization. Thanks to this, collapsing sections with very large numbers of rows should be faster now.
    • CollapsableTableView has been made compatible with static cells in Storyboard.
  • 2013/02/10
    • Fixed crash that occurred when double-tapping the header of a large section. 
  • 2013/09/08
    • Fixed crash that occurred on iOS 6.1 on rotation when using custom header views.
    • Enabled immediate programmatic row-selection or row-scrolling upon section-expansion.
  • 2013/09/23
    • Fixed bug where the collapsed/expanded indicator did not show up after adding rows to an empty section.  
  • 2013/10/26
    • Optimized for iOS 7. When running on iOS 7 or above, the collapsible table view now uses separate xib-files for the headers and footers of the table's sections to adapt to the new look of iOS 7. 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)