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 6 | iOS 7 + |
| |
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
:
#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:
#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 CollapsableTableViewHeaderViewController
s 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.
- (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:
#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.
- (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.
- (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
:
#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
- 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.