Johan Sørensen

Touches and UIScrollView inside a UITableView

UPDATE: please don’t don’t do it like this, it was only needed on iPhoneOS 2.×. A lot have changed in iOS since then and you can now do it properly

"Trafikanten" for the iPhone is the iPhone incarnation of the betabrite-style signs hanging around Oslo, providing travellers with real-time departure information on busses, trams and subways. So incorporating some of that feeling into the application, while still maintaining that iPhone look n’ feel was a crucial UI design issue for us.

A betabrite sign is basically a set of LED lamps that turn on and off in sequences, usually to portray text scrolling across the screen. Bringing this metaphor over to the iPhone means that the user should be able to scroll said text across the screen with his fingers. When this text lives in a subview of UITableView (which in turn is a UIScrollView subclass), there be dragons ahead!

The thing is, a UITableView takes completely control of the responder chain (and therefore touches) so that it can try and figure out if the user intents to scroll the scrollview, as described in the documentation overview for the UIScrollView class. So in order to be able to scroll part of a single cell sideways, while still being able to scroll the entire tableview up and down, we have to do some careful juggling with events and the responder chain.

Please observe this artist rendition of the view hierarchy:

 ______________________
|  UITableView         |
|  __________________  |
| | UITableViewCell  | |
| |  ______________  | |
| | | UIScrollView | | |
| | |______________| | |
| |__________________| |
|  __________________  |
| | UITableViewCell  | |
| |  ______________  | |
| | | UIScrollView | | |
| | |______________| | |
| |__________________| |
|______________________|

The innermost UIScrollView holds the departure times and it’s the view we want to be able to move horizontally. It’s worth pointing out that generally, for UITableViewCells, you’d want to flatten the view hierarchy as much as posible (e.g. less subviews) to achieve smooth scrolling performance. But in this case we knew that there would always be less than two dozens or so of UITableViewCells displayed in the tableview.

I created a UIScrollView subclass for the innermost one, that overrides all the touchesMoved:withEvent: and friends delegates, and then in each one try to figure out which direction an ongoing touch was heading. If the touch went up or down we’d call super, otherwise pass it along the responder chain (up to the tableview), so that it can do its thing:

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
  if ([self isTouchGoingLeftOrRight:[touches anyObject]]) {
    [super touchesMoved:touches withEvent:event];
  } else {
    [self.nextResponder touchesMoved:touches withEvent:event];
  }
}

isTouchGoingLeftOrRight: tries to figure out the general direction of the gesture, by comparing the current location with the previous location, and sets an instance variable that things like touchesEnded:withEvent: can react to. Make sure to leave some “wiggle room” in the direction detection, since the user seldom moves completely linearly.

So that takes care of that right? We can go up and down, otherwise we pass it along to the next responder. Turns out there’s one more thing you’d need to pull out of the sleeve; hit detection.

We have to override hitTest:withEvent: on the actual UITableView itself. Remember that it takes control of the responder chain, so the subviews won’t get a chance to handle it first, unless we explicitly override that behavior:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
  if (self.decelerating) {
    // don't try anything when the tableview is moving..
    return [super hitTest:point withEvent:event];
  }
  
  // Find the cell
  NSIndexPath *indexPathAtHitPoint = [self indexPathForRowAtPoint:point];
  id cell = [self cellForRowAtIndexPath:indexPathAtHitPoint];
  // if the cell has a scrollView property, it's the one we want
  if (cell != nil && [cell respondsToSelector:@selector(scrollView)]) {
    [[cell scrollView] setScrollEnabled:YES];
    // Return the innermost scrollview
    return (UIView *)[cell scrollView];
  }
  
  return [super hitTest:point withEvent:event];
}

hitTest:withEvent: is responsible for telling the system which view that was hit, by default UITableView assumes itself (or one of its cells), so we have to figure out if the user touches our inner scrollview, and if so return that view instead.

I can’t help but feel that there must be a better way of doing this, rather than try and outsmart the UIScrollView’s intended behaviour. But I guess that’s the tax for straying just a tad off the beaten path.