UIScrollView (Zooming)

The zooming behavior of UIScrollView is counterintuitive and not terribly well documented – particularly when one is dealing with a tiled scroll view. Today I present a simple “infinite zoom” demo (over an admittedly pretty bland world) that I hope will helpfully illustrate a concise zoom implementation.

Resources

First, credit where credit is due. I recommend reading the “Scrolling Madness” homepage, which contains a lot of information about UIScrollViews in general and zooming in particular. I also direct your attention to AAPL’s “ScrollViewSuite” demo code. Unfortunately, the download of that project weighs in at close to 70MB, and the zoom implementation isn’t as clear as I’d like, for the reasons discussed below.

Why Another Demo?

You might reasonably ask if the world needs another zooming demo. I think that it would benefit from one, as I found the existing resources somewhat overcomplicated. In particular, AAPL’s demo uses a system of differently-scaled, pre-rendered images to perform its drawing (hence the 70MB download) that, I think, obscures what is going on. That demo also subclasses UIScrollView, and hardcodes the subclass as its own delegate, in a somewhat non-general way.

The Goal

I want to create a UIScrollView containing a zoomable, pan-able virtual view. To keep things simple, the view will be of a flat gray field.

The Code

You can download an Xcode project that builds the demo here: it’s a modified version of the View-Based Application template.

NIB

NestingLet’s start with Interface Builder. The NIB for this project is a little fiddly, as a number of settings have to be got just right. To begin with, the main view controller’s NIB must contain two views:

  • An outer UIScrollView
  • An inner UIView

These views must be connected to the view controller:

  • The UIScrollView must be connected to the file owner/view controller’s view outlet
  • The UIView must be connected to the file owner/view controller’s content outlet (defined later)
  • The UIScrollView's delegate must be set to the file owner/view controller (which adopts the UIScrollViewDelegate protocol, as we’ll see later)

Nesting
Next, the geometry must be set up: The scroll view’s width and height should be set to 320 and 460 pixels, respectively (filling the application area), and the content inset should be set to 230 pixels top and bottom, and 160 pixels left and right. The 50% content inset allows us to move the corners of the content view to the center of the frame, and thereby to zoom in on or out from them.
Nesting

Finally, several attributes must be set. The scroll view must be set to “Always Bounce Horizontally” and “Always Bounce Vertically”, and its Min and Max zooms must be set to 0.5 and 2.0 respectively. (Note that, in this implementation, these settings control only the minimum or maximum change in zoom during any one pinch gesture; actual zoom is unconstrained, except by the limits of floating point precision.) Set the scroll view’s background to 100% opaque white.

Header

Here’s the declaration of the main view controller. It inherits from UIViewController, and adopts the UIScrollViewDelegate protocol. As you see, it includes a number of specialized members, the purpose of which will, hopefully, quickly become clear.

@interface zoomdemoViewController : UIViewController <UIScrollViewDelegate>
{
	CGSize			world;			// Overall size of the world, in "world units"
	CGFloat			scale;			// Scales world units to pixels
	NSMutableArray*		tiles;			// The set of all tiles ever created
	NSMutableSet*		extraTiles;		// The set of currently unused tiles
	CGRect			tileBox;		// The box of currently displayed tiles, in tile indices
	
	UIView*			content;		// A wrapper view around the tiles; used for zooming
}

@property (nonatomic, retain) IBOutlet UIView* content;

@end

Tiles

Unlike the UIView subclasses we used two weeks ago, today we’ll be using native UIViews to represent our tiles. This does rather leave the problem of “how would you draw an interesting world” as an exercise for the reader, but it also keeps the demo simpler. That said, here are the three helper methods (of the main view controller) that we’ll be using to manipulate tiles:

- (void)removeTiles
{
	for (UIView* tv in tiles)
	{
		// Remove tiles from content view
		[tv removeFromSuperview];
	}
	[tiles release];
	tiles = nil;
	
	[extraTiles release];
	extraTiles = nil;
}


- (void)createTiles
{
	if (tiles) [self removeTiles];
	
	tiles = [[NSMutableArray alloc] initWithCapacity:4];
	extraTiles = [[NSMutableSet alloc] initWithCapacity:4];
}


- (UIView*)addTileForFrame:(CGRect)frame
{
	UIView* tv = [extraTiles anyObject];
	if (tv)
	{
		// tv also retained by tiles
		[extraTiles removeObject:tv];
	}
	else
	{
		tv = [[UIView new] autorelease];
		tv.clearsContextBeforeDrawing = NO;
		tv.backgroundColor = [UIColor grayColor];
		[tiles addObject:tv];
		
		UILabel* label = [[[UILabel alloc] initWithFrame:CGRectMake(0, 0, tileSize.width, 18)] autorelease];
		label.backgroundColor = [UIColor clearColor];
		label.font = [UIFont systemFontOfSize:18];
		label.tag = labelTag;
		[tv addSubview:label];
	}
	
	[self.content addSubview:tv];
	tv.frame = frame;
	[tv setNeedsDisplay];
	return tv;
}

The interesting method is addTileForFrame:, which adds a tile for a specified frame to the content view; it reuses a tile if possible, otherwise it creates one. Tiles are created with a background gray color (representing the world) and a UILabel used for debugging; the label will display the tile’s (X,Y) index in tile space.

Utilities

The main view controller has two other helper methods:

- (void)sizeContent
{
	self.content.frame = CGRectMake(0, 0, world.width*scale, world.height*scale);
	self.scrollView.contentSize = CGSizeMake(CGRectGetMaxX(self.content.frame),
						 CGRectGetMaxY(self.content.frame));
}


- (void)reload
{
	// Recycle all tiles
	for (UIView* tv in [self.content subviews])
	{
		[tv removeFromSuperview];
		[extraTiles addObject:tv];
	}
	tileBox = CGRectZero;
	
	// Trigger re-tiling
	[self scrollViewDidScroll:self.scrollView];
}

The sizeContent method sets the size of the content view, as well as the scroll view’s contentSize, based on the overall world size and the current scale. Note that the content view is set to the size of the entire rendered world; this does not cause out-of-memory problems because the content view does not render any content directly; only those subviews in its visible area are rendered.

The main view controller also declares and defines an extension accessor for a UIScrollView:

- (UIScrollView*)scrollView
{
	return (UIScrollView*) self.view;
}

- (void)setScrollView:(UIScrollView*)newScrollView
{
	self.view = newScrollView;
}

As you can see, this just wraps the superclass’ view member.

Setup and Teardown

The main view controller’s viewDidLoad method handles setup, and it’s dealloc method handles cleanup:

- (void)viewDidLoad
{
	[super viewDidLoad];

	world = self.scrollView.frame.size;
	scale = .9;
	
	[self createTiles];
	tileBox = CGRectZero;
	
	[self sizeContent];
	self.scrollView.contentOffset = CGPointMake(-.05*self.scrollView.frame.size.width,
						    -.05*self.scrollView.frame.size.height);
	[self.scrollView flashScrollIndicators];
}


- (void)dealloc
{
	// Clear weak delegate reference
	if (self.scrollView.delegate == self)
	{
		self.scrollView.delegate = nil;
	}
	
	[self removeTiles];
	[content release];
	[super dealloc];
}

The only sort-of-cute thing here is the initial contentOffset; it’s chosen to center the initial world view, which is scaled to fill 90% of the scroll view. Also note that world and scale can be chosen to be any convenient values; the values here were chosen to produce an initial image that I liked.

Tiling

The scrollViewDidScroll: method is the heart of the tiling system:

- (void)scrollViewDidScroll:(UIScrollView*)scrollView
{
	// Tile width and height (in view space)
	CGFloat w = tileSize.width;
	CGFloat h = tileSize.height;
	
	// Legal region (in view space)
	CGRect legal = CGRectMake(0, 0, world.width*scale, world.height*scale);
	
	// Displayed region (in view space)
	CGRect viewport = CGRectIntersection([self.content convertRect:self.scrollView.bounds fromView:self.scrollView], legal);
	
	// For existing tiles
	for (UIView* tv in [self.content subviews])
	{
		// If there's no overlap with viewport, toss the tile
		if (!CGRectIntersectsRect(viewport, tv.frame))
		{
			[tv removeFromSuperview];
			[extraTiles addObject:tv];
		}
	}
	
	NSInteger oldMinCol = tileBox.origin.x;
	NSInteger oldMinRow = tileBox.origin.y;
	NSInteger oldMaxCol = CGRectGetMaxX(tileBox)-1;
	NSInteger oldMaxRow = CGRectGetMaxY(tileBox)-1;
	
	// For new tiles
	tileBox = CGRectIntegral(CGRectApplyAffineTransform(viewport, CGAffineTransformMakeScale(1/w, 1/h)));
	for (int bw = tileBox.size.width, n = bw*tileBox.size.height-1; n >= 0; n--)
	{
		NSInteger c = tileBox.origin.x + n % bw;
		NSInteger r = tileBox.origin.y + n / bw;
		
		// If missing, create
		if ((c < oldMinCol) || (c > oldMaxCol) || (r < oldMinRow) || (r > oldMaxRow))
		{
			UIView* tv = [self addTileForFrame:CGRectIntersection(CGRectMake(c*w, r*h, w, h), legal)];
			[(UILabel*)[tv viewWithTag:labelTag] setText:[NSString stringWithFormat:@"(%d, %d)",c,r]];
		}
	}
}

There are a few subtleties here:

  • The legal area is taken to be the projection of the world into view space
  • The viewport is defined as the intersection of the legal area with the projection of the scroll view’s bounds rectangle into view space; this projection is performed by the content view’s convertRect:fromView: method, which takes into account any zooming the UIScrollView might be doing
  • Any existing tiles that don’t overlap the viewport (i.e., are offscreen) are immediately recycled
  • The tileBox member tracks the currently displayed set of tiles; this set always forms a rectangle, and the origin of tileBox is the (column, row) index of the upper-left corner of that rectangle, while the width and height of tileBox are the number of tiles along each edge
  • The new set of tiles to be rendered is determined by scaling the viewport by the inverse tile size, and snapping the resulting rectangle to the smallest integrally-bounded rectangle that encompasses the scaled viewport
  • Any tiles in the new set that are outside the old set are created or recycled; such tiles are clipped to the legal area

Zooming

Now we come to the punchline of all this – the zooming code itself:

- (UIView*)viewForZoomingInScrollView:(UIScrollView*)scrollView
{
	return content;
}


- (void)scrollViewDidEndZooming:(UIScrollView*)scrollView withView:(UIView*)view atScale:(float)relScale
{
	// Cache offset before zoom scale is reset
	CGPoint offset = self.scrollView.contentOffset;
	
	// Transfer zoom from scroll view to world->view transform; resize content at the new scale
	self.scrollView.zoomScale = 1.0;
	scale *= relScale;
	[self sizeContent];
	
	// Restore the old offset
	self.scrollView.contentOffset = offset;
	
	// Re-render
	[self reload];
	
	// Flash
	[self.scrollView flashScrollIndicators];
}

It’s the scrollViewDidEndZooming:withView:atScale: method that’s obviously the interesting one here. In essence, it transfers the zoom from the UIScrollView (which performs a zoom by, more or less, scaling a bitmap) to the scale member, which governs the world-to-view transform. Since the overall zoom is the same both before and after this transfer, the same contentOffset is used. (The contentOffset is cached before the zoomScale is reset because changes to zoomScale affect contentOffset, among several other parameters.)

The Point

I realize we’ve covered a lot of ground without much motivation: Why is it necessary to move the zoom out of the UIScrollView at all? Why not just use the built-in zoom functionality exclusively?

The answer lies in the way UIScrollView performs zooming: it basically generates and scales a bitmap. If you zoom out 16x (a scale factor of 0.0625), the scroll view will generate a bitmap for an area 16x longer on each edge than the scroll view’s frame. This bitmap will cover 256x the area of the scroll view; for a full-screen view, it will cover 256*320*460 = 36Mpixels, which will probably cause memory problems. On the other hand, if you zoom in 16x, the scroll view will generate a bitmap for an area with 1/16th the edge length of the scroll view’s frame. This bitmap will cover 1/256th the area of the scroll view, and will pixelate badly when scaled up.

By moving the zoom out of the UIScrollView and into our tiling/rendering system, we can use a more sophisticated zooming approach. Here, since our world is a featureless gray wasteland, we just generate enough gray tiles to cover the pixels in the viewport. In a more interesting world, something more complex would be required, but any rendering system would benefit from drawing only enough pixels to cover the screen-space viewport on a one-for-one basis.

Share and Enjoy:
  • Twitter
  • Facebook
  • Digg
  • Reddit
  • HackerNews
  • del.icio.us
  • Google Bookmarks
  • Slashdot
This entry was posted in iPhone. Bookmark the permalink.