Signing Up UITableView

Dave Smith
Dave Smith

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

View Controller Outlets

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.