VoiceOver Bug and iOS 5 TableViews Revisited

Unfortunately I need to revisit the previous post about an iOS 5 bug that was preventing prototype table view cells working with VoiceOver. After posting I realised I had not quite done enough to work around the problem when using storyboards.

The Problem

In a nutshell there is a known bug in iOS 5 when VoiceOver is active which causes calls to the UITableView method dequeueReusableCellWithIdentifier: to fail to return a table view cell. I experienced this bug when playing with prototype cells created by a storyboard but I should be clear that the bug is not directly related to either storyboards or prototype cells (though it does have some extra impact for storyboards). You can experience the problem even if you not using storyboards and simply using a standard UITableView cell style:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
  static NSString *CellIdentifier = @"SubtitleCell";

  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
  if (cell == nil) {
    NSLog(@"Failed to dequeue cell");
    cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle
                                  reuseIdentifier:CellIdentifier];
  }
  // configure cell
  return cell;
}

With VoiceOver active on an iOS 5 device the above code will always fail to dequeue a cell and fall through to the cell allocation code (logging “Failed to dequeue cell”). Of course if you have registered a NIB with the table view you may be surprised when you do not get back a cell and crash. Hence my original workaround which was to always test that a cell is returned and manually allocate when VoiceOver is active.

Everlasting Cells

One of the impacts of this bug is that when VoiceOver is active table view cells never get reused. This means that as the table view is scrolled new cells are constantly being allocated. This is clearly visible in Instruments if you scroll a table view whilst running on an iOS 5 device with VoiceOver active. If you have very large table view cells and do lots and lots of scrolling you could in theory run out of memory. In practise I would guess that table view cells are small enough that normal table view activity is not sufficient to cause a serious problem.

Broken Seques

There is one other impact of this bug when using Storyboards that I missed when first attempting a workaround in my example storyboard app, WorldFacts. My initial attempt was simply to load the custom country cell from a separate XIB file using UINib method instantiateWithOwner:options:. This appeared to work but unfortunately since this new cell was not loaded via the storyboard it is not associated with any of the segues defined in the storyboard. This means that tapping a row in the table with VoiceOver active does nothing. The seque to the detailed country view controller is never triggered.

I think the reason I did not immediately spot this problem is that it does not occur if you activate VoiceOver when the table view cells have already been created from the storyboard. The easiest way to reproduce the problem is to ensure that VoiceOver is active before the App is launched.

Manually Triggering A Segue

To trigger the segue for the cells we have created with VoiceOver active we could just simply perform the required segue with performSegueWithIdentifier:sender: in the table view delegate method tableView:didSelectRowAtIndexPath:. However we need to be careful not to manually trigger the segue for normally created cells as the storyboard will also perform the segue for those cells.

To avoid triggering the seque twice I ended up using a different cell identifier for the cells that I am manually creating when VoiceOver is active. So my final implementation of tableView:cellForRowAtIndexPath: starts as follows:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
  UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:UYLCountryCellIdentifier];

This is the original cell creation using the cell identifier (UYLCountryCellIdentifier) for the prototype cell registered in the storyboard. In normal conditions this will always return a cell but with VoiceOver active on iOS 5 it returns nil.

if (cell == nil) {
  cell = [self.tableView dequeueReusableCellWithIdentifier:UYLNibCountryCellIdentifier];
  
  if (cell == nil) {
      [self.countryCellNib instantiateWithOwner:self options:nil];
      cell = self.countryCell;
      self.countryCell = nil;
  }
}

When I fail to get a cell from the storyboard I then attempt to dequeue a cell with the identifier (UYLNibCountryCellIdentifier) of the cell in the registered XIB file. This is of course futile since with VoiceOver active it will also fail. Finally I instantiate a new cell using the cached UINib object (refer to the previous post for further details).

Once we have identified our manually created cells we can implement the table view delegate method tableView:didSelectRowAtIndexPath: to manually trigger the segue to the detailed view for just these cells:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
  UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
  if ([cell.reuseIdentifier isEqualToString:UYLNibCountryCellIdentifier])
  {
    [self performSegueWithIdentifier:UYLSegueShowCountry sender:cell];
  }
}

Note that once we have called performSequeWithIdentifier:sender: for our manually allocated cell we end up back at the prepareForSegue:sender: method as for a correctly created cell and the view transition completes.

All Done

I hope that really is everything now to workaround this annoying bug when using storyboards. Unfortunately even with the imminent release of iOS 6 it seems likely that we will have to live with this bug for as long as iOS 5 support is required. See my GitHub CodeExamples repository for the updated WorldFacts App.