Let’s add on to our previous CATiledLayer
demo by implementing zooming. This is one of those things that worked a lot better than I thought it would, but, arguably, still didn’t work well enough. The built-in zoom support in CATiledLayer
integrates well (i.e., easily) with a UIScrollView
, but it doesn’t quite work the way I’d like, and it’s not obvious how it might be tweaked to work better. Let’s take a look at some code, and I’ll show you what I mean. (You can download the complete project for this week’s demo here.)
Continuing On
I’m going to pick up from last week’s demo, which you can download here. The changes are pretty straightforward. Begin by opening up zoomdemoViewController.m
, and adding this #import
:
#import <QuartzCore/QuartzCore.h>
Next, add a minimal viewForZoomingInScrollView:
method:
- (UIView*)viewForZoomingInScrollView:(UIScrollView*)scrollView
{
return content;
}
Now we’re ready to set up levels of detail in the CATiledLayer
.
Levels of Detail
Recode the sizeContent
method in zoomdemoViewController.m
to look like this:
- (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));
CGSize vpSize = self.scrollView.frame.size;
CGFloat zoomOutLevels = MAX(ceil(log2(MAX(world.width*scale/vpSize.width, world.height*scale/vpSize.height))), 0);
CGFloat zoomInLevels = 10;
[(CATiledLayer*)self.content.layer setLevelsOfDetail:zoomOutLevels+zoomInLevels+1];
[(CATiledLayer*)self.content.layer setLevelsOfDetailBias:zoomInLevels];
self.scrollView.minimumZoomScale = pow(2, -zoomOutLevels);
self.scrollView.maximumZoomScale = pow(2, zoomInLevels);
[self.content.layer setNeedsDisplay];
}
Let me say a word about what’s going on here.
There are two distinct-but-related zooming mechanisms in play in this code: The UIScrollView
has a minimum and maximum zoom level that controls the range of scale factors applied to its content (or, anyway, to the UIView
returned by viewForZoomingInScrollView:
— an exact description of what’s going on is surprisingly hard to pin down), while the CATiledLayer
inside the TiledView
has a range of levels of detail that it uses to render different tiles at different zoom levels.
So, if you configure the UIScrollView
with a minimumZoomScale
of 0.125 and a maximumZoomScale
of 8, you’ll be able to zoom in or out by a factor of 8. In such a case, you’d want to configure the CATiledLayer
with a levelsOfDetail
of 7, and a levelsOfDetailBias
of 3. This configuration creates 7 levels of detail, where each level is half the resolution of its predecessor, the “first” 3 levels of detail are “magnified”, the 4th is “normal” resolution, and the “last” 3 are at reduced resolutions. Continuing the example, if you had a 1024×1024 TiledView
, the just-described 7 levels of detail would render the view at:
Level 0 | 8192×8192 |
Level 1 | 4096×4096 |
Level 2 | 2048×2048 |
Level 3 | 1024×1024 |
Level 4 | 512×512 |
Level 5 | 256×256 |
Level 6 | 128×128 |
When you apply a scaling transform to a CATiledLayer
, the effect is a little like combining a camera’s optical and digital zoom. The CATiledLayer
selects the “best” level of detail to use for the given scale factor, and renders tiles at that level of detail. For instance, if you were applying a scale factor of 0.4 to the layer, then the tiles would probably be rendered from the 50% level of detail (Level 4 in our running example) and a 0.8 scale factor applied to those tiles. This arrangement will tend to produce a higher-quality, more efficient image than would be obtained by simply applying a raw scaling transform to the full-resolution tiles.
With all that said, we can understand sizeContent
a bit better. This function:
- Determines the maximum scale factor that can be applied to the viewport s.t. it doesn’t exceed the size of the world in at least one dimension (remember that scaling up the viewport is the same thing as scaling down the world)
- Finds the smallest power-of-two greater than or equal to that scale factor
- Floors the preceding result at 0; this is the number of “minified”, or “zoom out” levels of detail
- Arbitrarily sets the number of “magnified” or “zoom in” levels to 10
- Sets the number of levels of detail to 1, plus the number of “zoom out” levels, plus the number of “zoom in” levels
- Sets the levels of detail bias to the number of “zoom in” levels
- Calculates the
UIScrollView's
minimum and maximum zoom from the number of “zoom in” and “zoom out” levels
TiledView
The big change to TiledView
has to do with tile index calculation. As mentioned last time, tile indices aren’t really used for anything critical (just debugging) but this is an opportunity to get some insight into how things work. To begin with, change the “Calculate tile index” section of drawLayer:inContext:
to this:
CGSize tileSize = [(CATiledLayer*)layer tileSize];
CGRect tbox = CGRectApplyAffineTransform(CGRectMake(0, 0, tileSize.width, tileSize.height),
CGAffineTransformInvert(CGContextGetCTM(context)));
CGFloat x = box.origin.x / tbox.size.width;
CGFloat y = box.origin.y / tbox.size.height;
CGPoint tile = CGPointMake(x, y);
And, just to keep things clear, you should probably update the comment at the top of this method, as well:
- (void)drawLayer:(CALayer*)layer inContext:(CGContextRef)context
{
// Fetch clip box in *world* space; context's CTM is preconfigured for world space->tile pixel space transform
CGRect box = CGContextGetClipBoundingBox(context);
…
The issue here is that once we start using (and zooming) a CATiledLayer
we lose the nice world/view/tile space hierarchy that we saw before. The CTMs of the CGContextRefs
passed to drawLayer:inContext:
are set up to do a transformation from world space to a tile pixel space. On the one hand, it’s nice that the CTMs are set up for us, and that we can just pass world coordinates into them, but the loss of an explicit view space and the device-dependent (and zoom-dependent) world-space size of the tiles make it more difficult to reason about what’s going on.
In order to calculate the index of the tile being rendered, we need to divide the tile’s world-space origin by the world-space size of a tile. We can’t consistently get this size from the tile’s clip box, because the leftmost and bottommost tiles may be smaller than normal if they are themselves clipped against the layer’s bounds. Therefore, we have to convert a full-size tile from tile pixel space into world space; we do this by inverting the context’s CTM and applying the result to a tile’s pixel-space rectangle.
And that’s pretty much it. You can run the demo, and see the zooming goodness. As I said at the top, however, I’m not entirely satisfied with the results.
Complaints
To begin with the least important objection, this arrangement doesn’t provide the effectively infinite zoom we’ve seen in earlier demos. This is probably ok; we should be able to calculate a broad “interesting” range of zooms in almost all cases.
More seriously, the integration of the zooming of CATiledLayer
and UIScrollView
subverts the goal of only rendering those pixels that are displayed; if the UIScrollView
has a zoomScale
of 0.51, then almost twice as many pixels will be rendered as are displayed, since this zoom isn’t quite low enough to trigger the next-lowest level of detail. Interestingly, the CATiledLayer
always seems to select the level of detail that renders at least as many pixels as are to be displayed — at least this avoids ugly “pixelation” artifacts.
To return to a point mentioned above: the arrangement of components in this demo upsets the nice hierarchy of spaces that we’ve seen before. In particular, the client space of the TiledView
(or CATiledLayer
) is, effectively, world space. Aside from being slightly confusing, this makes the process of resizing the world — which might make sense for certain applications — potentially more complex.
Finally, performance — at least in the iPhone4 simulator — is a little scary. Things seem to run ok in standard resolution (on either simulated or actual hardware) but high-res tiling can take a long time to fill up the screen. I’m not sure what’s behind this. Also, attempts to force the demo to run in standard resolution in high-res environments weren’t successful; setting either the UIView's
contentScaleFactor
or the CATiledLayer's
contentsScale
properties didn’t seem to do anything.
Part of the performance problem seems to be that the simulator is rendering (roughly) between 5×7 and 10×13 tiles per screen; at a 256×256 tile size, that’s between 1280×1792 and 2560×3328 pixels drawn per 640×920 pixel screen, translating to an overdraw of between 4x and 14x. Either I’m missing something, or the simulator (at least) has some serious problems. (At a guess, I’d say those problems have to do with doubling the doubled resolution, due to the atypical specification of tile size in pixels, rather than points.)
Pingback: Hit Detection for Zoomed UIScrollViews | Things that were not immediately obvious to me