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
Let’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’sview
outlet - The
UIView
must be connected to the file owner/view controller’scontent
outlet (defined later) - The
UIScrollView's
delegate
must be set to the file owner/view controller (which adopts theUIScrollViewDelegate
protocol, as we’ll see later)
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.
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’sconvertRect:fromView:
method, which takes into account any zooming theUIScrollView
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 oftileBox
is the (column, row) index of the upper-left corner of that rectangle, while the width and height oftileBox
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.
Pingback: Things that were not immediately obvious to me » Blog Archive » Minesweeper (Part 2)