Thanks mainly to Apple's design choices when building the Settings app for iOS devices,
the Grouped-Style UITableView has become the de facto standard UI for app developers
to use when creating settings or account signup screens within their own applications.
In many ways, this is a very helpful standard to follow due to the amount of styling
that we developers get for free. However, regardless of styling, UITableView
is a
widget designed to provide a memory-efficient and user-performant method of displaying
large sets of data in a scrollable list. The primary technique behind this design
is the reuse, or recycling, of the UITableViewCell
objects that displays the data
to the user.
Essentially, UITableView
will only create enough cell objects to fill the screen.
Then, as rows scroll off the screen, those cells get reused to present the data
for the next item scrolling on-screen. For a standard 44px UITableViewCell
, this
means there are a maximum of about 10-11 cells in memory at any one time.
Despite the performance benefit this creates for large data sets, it creates some
unwanted side-effects when developers use UITableView
to create interactive elements
for data entry.
If you design signup tables like the screenshot, in which the table is never larger
than the content view, even with an input view visibile. These issues have likely
never plagued you since UITableView
loads all the cells your table requires and
has no need for reuse. However, as soon as enough cells exist to be covered and
require scrolling, the games begin.
Headache #1: Minor annoyance
The first issue this creates may not always be a problem. In fact, some may argue this should never be a problem and its presence is a product of bad design. Regardless, what I am referring to is storage of the data entered by the user. If your view controller is designed in such a way that it will only read the values of each user entry field when the user hits a "Save" button, you will find that it is difficult to read the value of a widget (such as UITextField) that has scrolled off-screen and was reused.
The solution to this problem can be simple enough without messing with UITableView
;
simply storing the values in some sort of model any time the user focus changes is
usually sufficient. This also allows the data to be reinserted in the cell if it
leaves the screen and then later returns, or if a low-memory situation causes the
entire view to be temporarily unloaded. Nevertheless, it is an issue that would
otherwise not exist without cell reuse.
Headache #2: More of a migraine
The more important issue when creating interactive tables is the need to transfer
the firstResponder
responsibilities from one cell to the next as the user either
presses a key on the input view, or taps another cell of their choice. This issue
can be even further compounded if not all the input views are a UIKeyboard
, and
you want the user to enter some data into fields through a UIPickerView
or something
more custom. Prior to iOS 3.2, managing those custom views had to be done manually
(adding the subview and animating it into view) in tandem with properly telling other
text fields to obtain and resign firstResponder
status.
A prime example of this problem comes with the following scenario: the user taps
the first field in your table, which happens to be a text entry cell so the keyboard
is displayed. Now the user scrolls down to the bottom of the table (making the
active cell scrolls off-screen), and selects a second cell where you have attached
a picker as the input view. In order to dismiss the keyboard, you need to tell the
original text field to resignFirstResponder
. However, good luck finding the field,
or the cell that contained it for that matter...they've been recycled. If you're
lucky the user will hit the return key, which will pass the active text field to
textFieldShouldReturn:
, allowing you to send the resignFirstResponder
message
to the right object.
As the input mechanisms created around your signup table get more complex, so does the probability that you will create situations where input views will be left dangling and can't be properly dismissed.
A Solution
A solution to this problem is to say that table cell reuse on a small and finite
set of data, such as a handful of entry fields, does more harm than good.
In these situations, we should let UITableView
load the entire table into memory
(as it would if the table were small enough to fit on the screen) and freely
operate on all the cell objects without fear of losing them to recycling.
In order to do this, we simply set the frame of UITableView
to match its contentSize
parameter after the content calculations are made. Once UITableView
has had a
chance to query its delegate for the number of rows and sections, plus the height
of each, its contentSize
is set to a value representing the entire length of the
table content. By setting the frame of the view to match, we increase the amount
of "visibile" data and UITableView
loads all the cells at once.
The side-effect of doing this is that UITableView
will no longer scroll.
Just like any other UIScrollView
, it will not scroll in either direction if
contentSize
is not larger than frame. To combat this problem we wrap the table
in a plain UIScrollView
to handle the user interactions of scrolling instead.
Because of the hierarchy created here, it is not required to disable scrolling
on the table (drag touches will be consumed by the parent UIScrollView
),
although you may do so if you prefer. Below is a sample view controller to
describe this further:
ViewController.xib
Notice the hierarchy of the UITableView
as a subview of UIScrollView
,
which in turn is attached to the view outlet.
ViewController.h
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController <UITableViewDataSource,UITableViewDelegate> {
UITableView *theTable;
UIScrollView *container;
}
@property (nonatomic,retain) IBOutlet UITableView *theTable;
@property (nonatomic,retain) IBOutlet UIScrollView *container;
@end
ViewController.m
#import "ViewController.h"
@implementation ViewController
@synthesize theTable;
@synthesize container;
- (void)dealloc
{
[theTable release]; theTable = nil;
[container release]; container = nil;
[super dealloc];
}
#pragma mark - Private Methods
- (void)scaleTableToContents {
[theTable setFrame:CGRectMake(theTable.frame.origin.x,
theTable.frame.origin.y,
theTable.contentSize.width,
theTable.contentSize.height)];
[container setContentSize:[theTable contentSize]];
}
#pragma mark - View lifecycle
// Implement viewDidLoad to do additional setup after loading the view, typically from a nib.
- (void)viewDidLoad
{
[super viewDidLoad];
[theTable setBackgroundColor:[UIColor clearColor]];
}
/*
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if(animated) {
[self scaleTableToContents];
} else {
[self performSelector:@selector(scaleTableToContents) withObject:nil afterDelay:0.3];
}
}
*/
#pragma mark - Table Callback Methods
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
return 1;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return 15;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"Cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
}
NSLog(@"Fetching cell for row %i",indexPath.row);
[[cell textLabel] setText:[NSString stringWithFormat:@"Table Row %i",indexPath.row]];
return cell;
}
@end
With the viewDidAppear:
method commented out as it is above, the table behavior
will be the default behavior. You will see NSLog
statements every time a row
scrolls on-screen and needs to be fetched again. By removing the comment block from
viewDidAppear:
, the sample code will scale the UITableView
and its UIScrollView
container to fit the entire table contents. In this case, you will see NSLog
statements for all table cells at once; nothing is recycled as the table is scrolled.
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if(animated) {
[self scaleTableToContents];
} else {
[self performSelector:@selector(scaleTableToContents) withObject:nil afterDelay:0.3];
}
}
You May Have Noticed a Caveat
The key to making this work lies in something I briefly mentioned earlier, but will
reiterate here. UITableView
must have had a chance to inspect the delegate for
the information necessary to calculate its contentSize
before you can scale the
views. In cases where your view controller is presented in an animated fashion,
ample time has been allowed for this by the time viewDidAppear:
is called.
However, in instances where animation did not take place (like if this view controller
is the initial view of your application) a slight delay is required; I use the
typical animation duration default of 0.3 seconds.
All in all, this is a very simple solution to a specific problem. But as in-app account management becomes more and more common, it is a problem you will find yourselves needing to solve regularly.