Friday, October 26, 2012

Height-for-width layout with CATextLayer


For a project I’m working on, I needed a Core Animation text layer that could adapt its height depending on the available width. This is commonly called a “height-for-width” layout. The stock CATextLayer and CAConstraintLayoutManager can’t really do this, but since you can implement custom layout managers, that’s where I started.
The two most important methods of the layout manager protocol are:
// To implement in the CALayoutManager implementation:
- (CGSize)preferredSizeOfLayer:(CALayer *)layer;
- (void)layoutSublayersOfLayer:(CALayer *)layer;
The idea was to override preferredSizeOfLayer: to special-case any CATextLayers that had wrapping enabled and calculate the needed height for any given width. My first somewhat naive attempt was to use the AppKit’s NSString additions likesizeWithAttributes: or boundingRectWithSize:options:attributes:. The results were almost right but not quite the same as what Core Animation itself would get. Using this approach, the layer would adjust its height according to the available width, but not exactly right. For some widths, the height would be one line to tall or short.
The second option was to use the Cocoa text system and put together the various pieces in order to measure the height with some more control over the process. This consisted of creating an NSTextStorage instance (and setting up the text and its attributes), an NSTextContainer instance (with the right width, and “infinite” height), and an NSLayoutManager instance. After putting those together and forcing a layout which is otherwise done lazily, I got the bounds from the layout manager. The code looked something like this:

// Measures the height needed for a given width using the Cocoa text system:
- (CGSize)frameSizeForTextLayer:(CATextLayer *)layer
{
    NSTextStorage *storage;
    if ([layer.string isKindOfClass:[NSAttributedString class]]) {
        storage = [[NSTextStorage alloc] initWithAttributedString:layer.string];
    } else {
        storage = [[NSTextStorage alloc] initWithString:layer.string];

        /* ... set up the attributes for the storage, like the font ... */
    }

    NSTextContainer *container = [[NSTextContainer alloc] initWithContainerSize:NSMakeSize(layer.bounds.size.width, FLT_MAX)];
    NSLayoutManager *manager = [[NSLayoutManager alloc] init];

    [manager addTextContainer:container];
    [storage addLayoutManager:manager];

    // The text layer doesn't use fragment line padding.
    [container setLineFragmentPadding:0];

    // Force layout, since it's done lazily.
    [manager glyphRangeForTextContainer:container];

    return [manager usedRectForTextContainer:container].size;
}
The result was much better this time, as a matter of fact so good that I thought it was finally right. But then I discovered that for some fonts there was still a small difference between CATextLayer’s measurements and mine. I could not find any parameters that would remove the differences, but some investigations seemed to indicate that the difference was in how the line heights, line spacing or maximum line height was set up. Or perhaps there is some rounding going on in the Cocoa text system to get text lines to end up on evenly aligned pixel boundaries? Either way, I didn’t really feel like going the trial-and-error way to get the (hopefully) right results…
After doing some more debugging in Xcode, it looked like CATextLayer actually uses Core Text directly, so I decided to try that next. This was quite similar to using the Cocoa text system, not surprising as the latter is built as a quite thin layer on top of the former.
Finally, it looked like the results were matching CATextLayer! :) The code that does the text measuring now looked like the following:
// Measures the height needed for a given width using Core Text:
- (CGSize)frameSizeForTextLayer:(CATextLayer *)layer
{
    NSAttributedString *string = [self attributedStringForTextLayer:layer];
    CTTypesetterRef typesetter = CTTypesetterCreateWithAttributedString((CFAttributedStringRef)string);
    CGFloat width = layer.bounds.size.width;
    
    CFIndex offset = 0, length;
    CGFloat y = 0;
    do {
        length = CTTypesetterSuggestLineBreak(typesetter, offset, width);
        CTLineRef line = CTTypesetterCreateLine(typesetter, CFRangeMake(offset, length));
        
        CGFloat ascent, descent, leading;
        CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
        
        CFRelease(line);
        
        offset += length;
        y += ascent + descent + leading;
    } while (offset < [string length]);
    
    CFRelease(typesetter);
    
    return CGSizeMake(width, ceil(y));
}
The method attributedStringForTextLayer: sets up an NSAttributedString correctly from the string, font and fontSize properties of a text layer.
At this point, the height-for-width layout was halfway through complete. The remaining issue left to solve was how make the layout manager do something useful with the calculated height and use it as an input for the regular constraints, for example to place something above the text layer, or to make a nice looking box sized to fit the wrapped text. But that, and the full source code will be available in an upcoming post soon. Stay tuned!
Let’s start with an illustration to show what the end goal is:
  
The text is automatically wrapped to fit the available size, which by itself is nothing new, as long as you manually provide the width and height, but in this case the size needs to be calculated from the height.

Layout managers

The way Core Animation handles laying out layers is through the layout manager protocol:
- (CGSize)preferredSizeOfLayer:(CALayer *)layer;
- (void)layoutSublayersOfLayer:(CALayer *)layer;
- (void)invalidateLayoutOfLayer:(CALayer *)layer;
In this post I will focus on the two former, preferredSizeOfLayer: andlayoutSublayersOfLayer:. The latter is useful when you are caching results and need to invalidate them, but we ignore that for now.

Constraints

The layout manager implementation shipped with Core Animation is called CAConstraintLayoutManager, and works by letting you apply constraints on certain properties of the layer’s geometry. Those are min/mid/max for x/y, width and height. You can also scale and offset the resulting values by constant values which gives a very high level of freedom to set up simple or complex relationships between different layers. In our example, as seen on the images above, we have three layers: the white background, the blue frame, and the text. As I want the blue frame to be resized to fit the text, the easiest setup was to add both the blue frame and the text layer as direct sublayers of the white background. The constraints then become:
  • Resize the text layer’s width to follow the width of the background (with some margin)
  • Center the text layer horizontally within the background
  • Adjust the text layer’s bottom at the bottom of the background (with some margin)
  • Make the blue frame follow the size of the text layer (with padding)
  • Center the blue frame relative to the text layer
With the standard layout manager, this results in the text layer being one line and the text to be truncated at the right edge. This is because the text layer reports its preferred frame size to have the height of one line of text.

The height-for-width layout manager

Enter our custom layout manager. Since we still want to be able to use constraints, we subclass the constraints layout manager. By overriding its method preferredSizeOfLayer:and have it assign the size we calculated in the previous post to the text layer, we can get the behavior we want (but with one caveat, more about that soon):
- (CGSize)preferredSizeOfLayer:(CALayer *)layer
{
    if ([layer isKindOfClass:[CATextLayer class]] && ((CATextLayer *)layer).wrapped) {
        CGRect bounds = layer.bounds;
        bounds.size = [self frameSizeForTextLayer:(CATextLayer *)layer];
        layer.bounds = bounds;
    }

    return [super preferredSizeOfLayer:layer];
}
The caveat is that the layout manager goes through the layers to resize and place them, including setting the width of the text. Setting the width of the text could change its height, and some constraints might need to be redone after doing that!
This means we also have to override layoutSublayersOfLayer: to add a small hack. We first invoke super’s implementation to handle the normal constraints based layout. Then we setup the new heights for any text layers as a result of the first pass. Finally we invoke super’s implementation again. As long as you don’t have constraints that would change the text width in the second pass, this works nicely.
- (void)layoutSublayersOfLayer:(CALayer *)layer
{
    // First let the regular constraints kick in to set the width of text layers.
    [super layoutSublayersOfLayer:layer];

    // Now adjust the height of any wrapped text layers, as their widths are known.
    for (CALayer *child in [layer sublayers]) {
        if ([child isKindOfClass:[CATextLayer class]]) {
            [self preferredSizeOfLayer:child];
        }
    }

    // Then let the regular constraints adjust any values that depend on heights.
    [super layoutSublayersOfLayer:layer];
}
This is obviously stretching the intention of the constraint layout manager, but it works for simple and common layer trees like the one described here. There are also some easy opportunities for optimizing the code, as I tried to keep it as simple as possible (such as caching the text measuring, and not laying out unless necessary).
I hope the posts and code will prove useful for someone else besides me. And as usual, if anyone knows a better way to do this, please let me know.
The code is available as an Xcode project in a git repo or source package.


No comments: