CATiledLayer (Part 1)

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 the TileView 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. With CATiledLayer, you must be prepared for your drawLayer: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. A CATiledLayer 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 the CATiledLayer'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 the TiledView 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.

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