Let’s take a look at a CATiledLayer
demo. I first ran across the CATiledLayer
class when I was looking into a multithreaded, tiled, vector-graphics rendering solution for the Demine project. I didn’t pursue it at that time because it looked like it would be a bit of a job to understand and deploy, and I already had a workable rendering engine based on blitting. Now, however, I’d like to return to it.
What I’m going to present today (you can download the complete project here) is very much a work-in-progess. This demo shows how the CATiledLayer
class can be made to do certain things, but it doesn’t address (at least) two very important problems: how to zoom, and how to handle the hazards of multithreading. I’ll talk briefly about both, but a thorough discussion will have to wait for another day. Now, without further preamble, let’s get started!
Project Setup
I’m going to begin with the vector graphics demo I did while developing Demine. Rather than recapitulate everything in that demo, I’m just going to incorporate it by reference; anything important that isn’t covered here is probably discussed in the earlier article, which I encourage you to consult.
Let’s get started by grabbing the earlier project. The first thing to do, since you’re probably building against SDK 4.0, is to update some project settings. (AAPL got very pushy about linking against the most recent SDK with 4.0.) First, open up the Project->Edit Project Settings dialog, select the “General” tab, and set the “Base SDK for All Configurations” to “iPhone Device 4.0”. Next, switch to the “Build” tab, check that the “Configuration” drop-down is set to “All Configurations”, and set the “iPhone OS Deployment Target” (in the “Deployment” section) to “iPhone OS 3.0”. (This will enable your app to run on iOS 3.0, if, like me, you haven’t upgraded your device yet.) Build and run the project, just to ensure the old stuff still works.
Tiled View
Now we’re going to add a tiled view class. Use Xcode’s “New File” feature to add a UIView
subclass called TiledView
to the project. Add this method to the automatically generated implementation file:
+ (Class)layerClass
{
return [CATiledLayer class];
}
This class method is the “magic” that customizes the layer inside instances of this class. Unfortunately, the reference to CATiledLayer
requires you both to #import <QuartzCore/QuartzCore.h>
, and to add the QuartzCore
framework to the project. Make those changes, then rebuild to check that everything compiles.
Delegate Method
A UIView
is automatically set as the delegate
of its layer
, and the key method of a CATiledLayer's
delegate
is drawLayer:inContext:
— let’s add one to our new class:
- (void)drawLayer:(CALayer*)layer inContext:(CGContextRef)context
{
// Fetch clip box in *view* space; context's CTM is preconfigured for view space->tile space transform
CGRect box = CGContextGetClipBoundingBox(context);
// Calculate tile index
CGFloat contentsScale = [layer respondsToSelector:@selector(contentsScale)]?[layer contentsScale]:1.0;
CGSize tileSize = [(CATiledLayer*)layer tileSize];
CGFloat x = box.origin.x * contentsScale / tileSize.width;
CGFloat y = box.origin.y * contentsScale / tileSize.height;
CGPoint tile = CGPointMake(x, y);
// Clear background
CGContextSetFillColorWithColor(context, [[UIColor grayColor] CGColor]);
CGContextFillRect(context, box);
// Rendering the paths
CGContextSaveGState(context);
CGContextConcatCTM(context, [self transformForTile:tile]);
NSArray* pathGroups = [self pathGroupsForTile:tile];
for (PathGroup* pg in pathGroups)
{
CGContextSaveGState(context);
CGContextConcatCTM(context, pg.modelTransform);
for (Path* p in pg.paths)
{
[p renderToContext:context];
}
CGContextRestoreGState(context);
}
CGContextRestoreGState(context);
// Render label (Setup)
UIFont* font = [UIFont fontWithName:@"CourierNewPS-BoldMT" size:16];
CGContextSelectFont(context, [[font fontName] cStringUsingEncoding:NSASCIIStringEncoding], [font pointSize], kCGEncodingMacRoman);
CGContextSetTextDrawingMode(context, kCGTextFill);
CGContextSetTextMatrix(context, CGAffineTransformMakeScale(1, -1));
CGContextSetFillColorWithColor(context, [[UIColor greenColor] CGColor]);
// Draw label
NSString* s = [NSString stringWithFormat:@"(%.1f, %.1f)",x,y];
CGContextShowTextAtPoint(context,
box.origin.x,
box.origin.y + [font pointSize],
[s cStringUsingEncoding:NSMacOSRomanStringEncoding],
[s lengthOfBytesUsingEncoding:NSMacOSRomanStringEncoding]);
}
A few quick remarks about this method:
- This method can be divided into 3 basic sections:
- Computing the tile index
- Rendering the tile
- Rendering the label
- Tile index computation is only presented as a placeholder; the index is used in the label and passed to some other functions, but never used for anything important. (More on this below.)
- The
layer's
tileSize
is specified in pixels; this means that tiles will have different view space sizes on normal vs. high-resolution devices. - The
layer's
contentsScale
property defines the relationship between view space points and backing store pixels. This property does not exist in runtime environments prior to iOS 4.0; in such environments it is always implicitly equal to 1. - The actual tile rendering code is essentially the same as that in the
drawRect:
method of theTileView
class that we’re replacing. - The label is rendered for debugging purposes only.
This drawLayer:inContext:
method invokes (and assumes the existence of) two instance methods:
- (CGAffineTransform)transformForTile:(CGPoint)tile
- (NSArray*)pathGroupsForTile:(CGPoint)tile
which we will now add to TiledView
.
Extension Methods
The transformForTile:
method is really a holdover from the earlier implementation, which required us to generate a world space -> tile space transform for each tile. Since the contexts
passed to drawLayer:inContext:
are pre-configured with a view space -> tile space transform, we only need to provide an additional world space -> view space transform, which doesn’t vary by tile. Which is all by way of saying that this function doesn’t make a lot of sense, and will almost certainly disappear in future versions of this demo.
Furthermore, since we’re not supporting zoom in this demo (despite its name!), this function doesn’t even do anything interesting; in the absence of zoom, the world space -> view space transform is just the identity:
- (CGAffineTransform)transformForTile:(CGPoint)tile
{
return CGAffineTransformIdentity;
}
The pathGroupsForTile:
method would normally return the set of PathGroups
that overlap a particular tile. As before, however, we’re just going to return the same set of PathGroups
for every tile, and rely on clipping to do the right thing. The following function is a very slight re-write of the preexisting pathGroupsForTileView:
method:
- (NSArray*)pathGroupsForTile:(CGPoint)tile
{
CGMutablePathRef p;
Path* path;
PathGroup* pg = [[PathGroup new] autorelease];
// Path 1
path = [[Path new] autorelease];
p = CGPathCreateMutable();
CGPathAddEllipseInRect(p, NULL, CGRectMake(-95, -95, 190, 190));
path.path = p;
path.strokeWidth = 10;
CGPathRelease(p);
[pg.paths addObject:path];
// Path 2
path = [[Path new] autorelease];
p = CGPathCreateMutable();
CGPathAddRect(p, NULL, CGRectMake(-69.5, -51.5, 139, 103));
path.path = p;
path.strokeWidth = 5;
CGPathRelease(p);
[pg.paths addObject:path];
// Center it, at some reasonable fraction of the world size
// * The TiledView's bounds area always equals the size of view space
// * With no scaling, the size of view space equals the size of world space
// * The bounding box of the preceeding model is 200x200, centered about 0
CGFloat scaleToFit = self.bounds.size.width*.25/200.0;
CGAffineTransform s = CGAffineTransformMakeScale(scaleToFit, scaleToFit);
CGAffineTransform t = CGAffineTransformMakeTranslation(self.bounds.size.width/2.0, self.bounds.size.height/2.0);
pg.modelTransform = CGAffineTransformConcat(s, t);
return [NSArray arrayWithObject:pg];
}
Note that I chose to identify the tile with a CGPoint
, representing a 2D index. This probably isn’t such a great choice, since, as mentioned previously, tiles can have different view space sizes depending on device resolution. It doesn’t matter right now (since I ignore the information anyway) but this is another thing that will probably change in future versions of this demo.
Code Swap
Okay, now to swap out our old TileView
approach for one based on TiledView
. (There’s no need to thank me for that naming convention, although I appreciate your kind thoughts.)
The swap is pretty straight-forward. First, open zoomdemoViewController.xib
, select the UIView
inside the UIScrollView
, open the Identity Inspector, and change its Class Identity to TiledView
. Next, remove a ton of stuff from the zoomdemoViewController
class:
Delete these members (and references to them):
tiles
extraTiles
tileBox
Delete these constants:
tileSize
labelTag
Delete these extension methods:
removeTiles
createTiles
addTileForFrame:
reload
Delete all UIScrollView
delegate methods:
scrollViewDidScroll:
viewForZoomingInScrollView:
scrollViewDidEndZooming:withView:atScale:
Delete all TileView
delegate methods (and, while you’re at it, drop the TileViewDelgate
protocol, and toss the TileView
files out of the project:
transformForTileView:
pathGroupsForTileView:
Add this line to the end of the sizeContent
method:
[self.content.layer setNeedsDisplay];
This line is a little peculiar. Without it, the TiledView
doesn’t display anything at all when I run the demo on my actual device (iPhone 3G running iOS 3.1.3); in the the simulator (iOS 4.0), on the other hand, the demo works fine without it. Particularly unusual is that the setNeedsDisplay
message must be sent to the layer
; the same message passed to the content
view has no effect.
Finally, to create a little visual interest (in the absence of zooming) recode viewDidLoad
to look like this:
- (void)viewDidLoad
{
[super viewDidLoad];
world = CGSizeMake(self.scrollView.frame.size.width*4, self.scrollView.frame.size.height*4);
scale = 1.0;
[self sizeContent];
self.scrollView.contentOffset = CGPointMake(1.5*self.scrollView.frame.size.width,
1.5*self.scrollView.frame.size.height);
[self.scrollView flashScrollIndicators];
}
(Don’t sweat the significance of the world
and scale
variables; in the interests of avoiding synchronization problems, they’re not particularly connected to the behavior of the TiledView
in this version of the code.)
Build and run the project, and hey presto: multithreaded, tiled, vector-graphics rendering!
Remarks
Okay — that was a lot of ground to cover. I think the result is pretty nice, though: we’ve got a smooth-scrolling, asynchronously rendered view, into which we should be able to stuff arbitrary amounts of vector graphics (hopefully only at the cost of slower rendering, and not a less-responsive UI) and there’s almost no code required to drive either the scrolling or the tiling. The bulk of the code is spent on simple rendering, and isn’t much more complex than it would be in the absence of the scrolling, tiling, and asynchronous behavior.
That said, there are a fairly large number of things missing from, and issues unaddressed by, this demo. Together, they make it unready for production use. Briefly:
- This code doesn’t support zooming. The
CATiledLayer
class does have built-in zoom support, but that support seems to be for power-of-two zooming — as opposed to the (nearly) infinite, arbitrary resolution zoom we’ve seen before, and that you’d want to have with vector graphics. (I haven’t explored the zoom support in detail, though.) - The best part of
CATiledLayer
— its multithreaded nature — is also it’s most problematic. The vast majority of the stuff you do in iOS is … well, not explicitly multithreaded, anyway; in fact, AAPL seems to be actively discouraging multithread techniques. WithCATiledLayer
, you must be prepared for yourdrawLayer:inContext:
method to be called from (multiple) background threads at any point in your main thread’s execution. This demo avoids synchronization issues because it never accesses mutable shared state from a background thread; this isn’t a viable solution in production. - This particular implementation of
CATiledLayer
will work properly (i.e., in high-resolution) on high-resolution devices, but this isn’t automatically true for all implementations. ACATiledLayer
must be configured to use a high-resolution backing store. Also, since tiling is defined in pixel space, supporting code may need to be aware of theCATiledLayer's
resolution. - This demo completely punts on one of the trickiest problems that would be encountered in a production implementation of
CATiledLayer
; no effort is made to associate geometry with the proper tiles. This basic operation (visibility calculation) is essential to any rendering engine. Furthermore, this demo doesn’t provide any way for code outside theTiledView
to change world size, world scale, or model geometry. - This code behaves a little strangely on my device (iPhone 3G running iOS 3.1.3). In addition to the previously mentioned
setNeedsDisplay
business, tiles don’t always properly (i.e. completely) fade-in on startup. I haven’t found any explanation for this latter glitch, nor can I screen-shot it (attempts to do so fix the glitch).
Acknowledgment
I want to acknowledge Bill Dudney’s work on this topic, which helped flesh out AAPL’s rather sparse documentation. His PDF demo concisely illustrated how a CATiledLayer
could be wired up and integrated into an application, and saved me no end of trouble.
Pingback: Things that were not immediately obvious to me » Blog Archive » CATiledLayer (Part 2)
Pingback: Things that were not immediately obvious to me » Blog Archive » Demine iOS 4.0 Upgrades (Resolution)
Pingback: Anonymous