AppKit has no built in support for resizable (or stretchable) images which has been bothering me for a while, so today lets look at adding it!
The interface and behaviour below is heavily modelled on that in iOS 6.
How Resizing Works
Given a single image, we chop it up into multiple parts.
The corners always stay exactly the same size, with only their position moving. Next the edge pieces are tiled in a single direction. Finally the center (orange) is scaled or tiled resulting in the below.
Adding to NSImage
While we could have likely done all this work directly in a category on NSImage. I like ivars and I don’t like adding logic directly to categories, so lets implement it in a subclass of NSImage, RHResizableImage.
That doesn’t mean we can’t provide a nice API directly on NSImage, which is what we do below, via way of a pass-through category.
Basic Process
- Provided with an image and a set of edge or cap insets.
- We use these to determine which parts to tile (or stretch) and which parts to lock in place.
- Split these parts into 9 separate image pieces.
- Override the NSImage
drawInRect:fromRect:operation:fraction:respectFlipped:hints:
method and draw our representation when asked, usingNSDrawNinePartImage();
. - Optionally cache our drawing.
- Discard the cache if the size or scale we are next asked to draw is different from our cached size and scale.
The Interface
In order to model the interface on iOS as closely as possible, we had to create a few new structures.
RHEdgeInsets
typedef struct _RHEdgeInsets {
CGFloat top, left, bottom, right; // specify amount to inset (positive) for each of the edges. values can be negative to 'outset'
} RHEdgeInsets;
extern RHEdgeInsets RHEdgeInsetsMake(CGFloat top, CGFloat left, CGFloat bottom, CGFloat right);
extern CGRect RHEdgeInsetsInsetRect(CGRect rect, RHEdgeInsets insets, BOOL flipped); //if flipped origin is top-left otherwise origin is bottom-left (OSX Default is NO)
extern BOOL RHEdgeInsetsEqualToEdgeInsets(RHEdgeInsets insets1, RHEdgeInsets insets2);
extern const RHEdgeInsets RHEdgeInsetsZero;
RHResizableImageResizingMode
typedef enum NSInteger {
RHResizableImageResizingModeTile,
RHResizableImageResizingModeStretch,
} RHResizableImageResizingMode;
We then proceed with the class interface as follows.
RHResizableImage Interface
@interface RHResizableImage : NSImage
...
-(id)initWithImage:(NSImage*)image leftCapWidth:(CGFloat)leftCapWidth topCapHeight:(CGFloat)topCapHeight;
-(id)initWithImage:(NSImage*)image capInsets:(RHEdgeInsets)capInsets;
-(id)initWithImage:(NSImage*)image capInsets:(RHEdgeInsets)capInsets resizingMode:(RHResizableImageResizingMode)resizingMode; //designated initializer
@end
Implementation
So we need to do a few things. First lets implement a little utility method to create an image from a subsection of another image.
As an aside, the arc_autorelease() that you see below comes from RHARCSupport.h
RHCapturePieceOfImageFromRect
NSImage* RHCapturePieceOfImageFromRect(NSImage *image, CGRect rect){
NSRect fromRect = NSRectFromCGRect(rect);
NSImage *newImage = [[NSImage alloc] initWithSize:fromRect.size];
if (newImage.isValid && fromRect.size.width > 0.0f && fromRect.size.height > 0.0f) {
NSRect toRect = fromRect;
toRect.origin = NSZeroPoint;
[newImage lockFocus];
//because we override drawInRect method in RHResizableImage, we need to call the super; non stretch implementation
if ([image isKindOfClass:[RHResizableImage class]]){
[(RHResizableImage*)image nonStretchedDrawInRect:toRect fromRect:fromRect operation:NSCompositeCopy fraction:1.0f respectFlipped:YES hints:nil];
} else {
[image drawInRect:toRect fromRect:fromRect operation:NSCompositeCopy fraction:1.0f respectFlipped:YES hints:nil];
}
[newImage unlockFocus];
}
return arc_autorelease(newImage);
}
Next up, we need a method to create all the various pieces to pass to NSDrawNinePartImage()
. We can keep them around in an array.
RHNinePartPiecesFromImageWithInsets
NSArray* RHNinePartPiecesFromImageWithInsets(NSImage *image, RHEdgeInsets capInsets){
CGFloat imageWidth = image.size.width;
CGFloat imageHeight = image.size.height;
CGFloat leftCapWidth = capInsets.left;
CGFloat topCapHeight = capInsets.top;
CGFloat rightCapWidth = capInsets.right;
CGFloat bottomCapHeight = capInsets.bottom;
NSSize centerSize = NSMakeSize(imageWidth - leftCapWidth - rightCapWidth, imageHeight - topCapHeight - bottomCapHeight);
NSImage *topLeftCorner = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(0.0f, imageHeight - topCapHeight, leftCapWidth, topCapHeight));
NSImage *topEdgeFill = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(leftCapWidth, imageHeight - topCapHeight, centerSize.width, topCapHeight));
NSImage *topRightCorner = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(imageWidth - rightCapWidth, imageHeight - topCapHeight, rightCapWidth, topCapHeight));
NSImage *leftEdgeFill = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(0.0f, bottomCapHeight, leftCapWidth, centerSize.height));
NSImage *centerFill = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(leftCapWidth, bottomCapHeight, centerSize.width, centerSize.height));
NSImage *rightEdgeFill = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(imageWidth - rightCapWidth, bottomCapHeight, rightCapWidth, centerSize.height));
NSImage *bottomLeftCorner = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(0.0f, 0.0f, leftCapWidth, bottomCapHeight));
NSImage *bottomEdgeFill = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(leftCapWidth, 0.0f, centerSize.width, bottomCapHeight));
NSImage *bottomRightCorner = RHImageByReferencingRectOfExistingImage(image, NSMakeRect(imageWidth - rightCapWidth, 0.0f, rightCapWidth, bottomCapHeight));
return [NSArray arrayWithObjects:topLeftCorner, topEdgeFill, topRightCorner, leftEdgeFill, centerFill, rightEdgeFill, bottomLeftCorner, bottomEdgeFill, bottomRightCorner, nil];
}
Now lets piece all this together.
First in our init method, we call out and create our image pieces.
initWithImage:capInsets:
-(id)initWithImage:(NSImage*)image capInsets:(RHEdgeInsets)capInsets resizingMode:(RHResizableImageResizingMode)resizingMode{
self = [super initWithData:[image TIFFRepresentation]];
if (self){
_capInsets = capInsets;
_resizingMode = resizingMode;
_imagePieces = arc_retain(RHNinePartPiecesFromImageWithInsets(self, _capInsets));
}
return self;
}
Then, each time we are asked to draw we perform the below drawing and caching logic.
drawInRect:fromRect:operation:fraction:respectFlipped:hints:
-(void)drawInRect:(NSRect)rect fromRect:(NSRect)fromRect operation:(NSCompositingOperation)op fraction:(CGFloat)requestedAlpha respectFlipped:(BOOL)respectContextIsFlipped hints:(NSDictionary *)hints{
CGContextRef context = [[NSGraphicsContext currentContext] graphicsPort];
//if our current cached image ref size does not match, throw away the cached image
//we also treat the current contexts scale as an invalidator so we don't draw the old, cached result.
if (!NSEqualSizes(rect.size, _cachedImageSize) || _cachedImageDeviceScale != RHContextGetDeviceScale(context)){
arc_release_nil(_cachedImageRep);
_cachedImageSize = NSZeroSize;
_cachedImageDeviceScale = 0.0f;
}
//if we don't have a cached image rep, create one now
if (!_cachedImageRep){
//cache our cache invalidation flags
_cachedImageSize = rect.size;
_cachedImageDeviceScale = RHContextGetDeviceScale(context);
//create our own NSBitmapImageRep directly because calling -[NSImage lockFocus] and then drawing an
//image causes it to use the largest available (ie @2x) image representation, even though our current
//contexts scale is 1 (on non HiDPI screens) meaning that we inadvertently would use @2x assets to draw for @1x contexts
_cachedImageRep = [[NSBitmapImageRep alloc]
initWithBitmapDataPlanes:NULL
pixelsWide:_cachedImageSize.width * _cachedImageDeviceScale
pixelsHigh:_cachedImageSize.height * _cachedImageDeviceScale
bitsPerSample:8
samplesPerPixel:4
hasAlpha:YES
isPlanar:NO
colorSpaceName:[[[self representations] lastObject] colorSpaceName]
bytesPerRow:0
bitsPerPixel:32];
[_cachedImageRep setSize:rect.size];
if (!_cachedImageRep){
NSLog(@"Error: failed to create NSBitmapImageRep from rep: %@", [[self representations] lastObject]);
return;
}
NSGraphicsContext *newContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:_cachedImageRep];
if (!newContext){
NSLog(@"Error: failed to create NSGraphicsContext from rep: %@", _cachedImageRep);
arc_release_nil(_cachedImageRep);
return;
}
[NSGraphicsContext saveGraphicsState];
[NSGraphicsContext setCurrentContext:newContext];
NSRect drawRect = NSMakeRect(0.0f, 0.0f, _cachedImageSize.width, _cachedImageSize.height);
[[NSColor clearColor] setFill];
NSRectFill(drawRect);
BOOL shouldTile = (_resizingMode == RHResizableImageResizingModeTile);
RHDrawNinePartImage(drawRect,
[_imagePieces objectAtIndex:0], [_imagePieces objectAtIndex:1], [_imagePieces objectAtIndex:2],
[_imagePieces objectAtIndex:3], [_imagePieces objectAtIndex:4], [_imagePieces objectAtIndex:5],
[_imagePieces objectAtIndex:6], [_imagePieces objectAtIndex:7], [_imagePieces objectAtIndex:8],
NSCompositeSourceOver, 1.0f, shouldTile);
[NSGraphicsContext restoreGraphicsState];
}
//finally draw the cached image rep
fromRect = NSMakeRect(0.0f, 0.0f, _cachedImageSize.width, _cachedImageSize.height);
[_cachedImageRep drawInRect:rect fromRect:fromRect operation:op fraction:requestedAlpha respectFlipped:respectContextIsFlipped hints:hints];
}
Implementing NSDrawNinePartImage()
In the code above and linked below, we use the system provided NSDrawNinePartImage()
by default, however in the interests of learning let’s re-implement its drawing code below. It might also be useful if you want to always stretch all parts of an image while resizing, because the NS version currently only supports tiling.
First up we need a method to draw an image tiled in a given rect. To do this we have to mostly drop down to the CoreGraphics layer and do the work there.
RHDrawTiledImageInRect
void RHDrawTiledImageInRect(NSImage* image, NSRect rect, NSCompositingOperation op, CGFloat fraction){
CGContextRef context = [[NSGraphicsContext currentContext] graphicsPort];
CGContextSaveGState(context);
[[NSGraphicsContext currentContext] setCompositingOperation:op];
CGContextSetAlpha(context, fraction);
//pass in the images actual size in points rather than rect. This gives us the actual best representation for the current context. if we passed in rect directly, we would always get the @2x representation because NSImage assumes more pixels are always better.
NSRect outRect = NSMakeRect(0.0f, 0.0f, image.size.width, image.size.height);
CGImageRef imageRef = [image CGImageForProposedRect:&outRect context:[NSGraphicsContext currentContext] hints:NULL];
CGContextClipToRect(context, NSRectToCGRect(rect));
CGContextDrawTiledImage(context, CGRectMake(rect.origin.x, rect.origin.y, image.size.width, image.size.height), imageRef);
CGContextRestoreGState(context);
}
Next up is a quick helper function to make our code simpler.
RHDrawImageInRect
void RHDrawImageInRect(NSImage* image, NSRect rect, NSCompositingOperation op, CGFloat fraction, BOOL tile){
if (tile){
RHDrawTiledImageInRect(image, rect, op, fraction);
} else {
RHDrawStretchedImageInRect(image, rect, op, fraction);
}
}
Finally the bulk of the drawing logic exists in the draw method.
RHDrawNinePartImage
void RHDrawNinePartImage(NSRect frame, NSImage *topLeftCorner, NSImage *topEdgeFill, NSImage *topRightCorner, NSImage *leftEdgeFill, NSImage *centerFill, NSImage *rightEdgeFill, NSImage *bottomLeftCorner, NSImage *bottomEdgeFill, NSImage *bottomRightCorner, NSCompositingOperation op, CGFloat alphaFraction, BOOL shouldTile){
CGFloat imageWidth = frame.size.width;
CGFloat imageHeight = frame.size.height;
CGFloat leftCapWidth = topLeftCorner.size.width;
CGFloat topCapHeight = topLeftCorner.size.height;
CGFloat rightCapWidth = bottomRightCorner.size.width;
CGFloat bottomCapHeight = bottomRightCorner.size.height;
NSSize centerSize = NSMakeSize(imageWidth - leftCapWidth - rightCapWidth, imageHeight - topCapHeight - bottomCapHeight);
NSRect topLeftCornerRect = NSMakeRect(0.0f, imageHeight - topCapHeight, leftCapWidth, topCapHeight);
NSRect topEdgeFillRect = NSMakeRect(leftCapWidth, imageHeight - topCapHeight, centerSize.width, topCapHeight);
NSRect topRightCornerRect = NSMakeRect(imageWidth - rightCapWidth, imageHeight - topCapHeight, rightCapWidth, topCapHeight);
NSRect leftEdgeFillRect = NSMakeRect(0.0f, bottomCapHeight, leftCapWidth, centerSize.height);
NSRect centerFillRect = NSMakeRect(leftCapWidth, bottomCapHeight, centerSize.width, centerSize.height);
NSRect rightEdgeFillRect = NSMakeRect(imageWidth - rightCapWidth, bottomCapHeight, rightCapWidth, centerSize.height);
NSRect bottomLeftCornerRect = NSMakeRect(0.0f, 0.0f, leftCapWidth, bottomCapHeight);
NSRect bottomEdgeFillRect = NSMakeRect(leftCapWidth, 0.0f, centerSize.width, bottomCapHeight);
NSRect bottomRightCornerRect = NSMakeRect(imageWidth - rightCapWidth, 0.0f, rightCapWidth, bottomCapHeight);
RHDrawImageInRect(topLeftCorner, topLeftCornerRect, op, fraction, NO);
RHDrawImageInRect(topEdgeFill, topEdgeFillRect, op, fraction, shouldTile);
RHDrawImageInRect(topRightCorner, topRightCornerRect, op, fraction, NO);
RHDrawImageInRect(leftEdgeFill, leftEdgeFillRect, op, fraction, shouldTile);
RHDrawImageInRect(centerFill, centerFillRect, op, fraction, shouldTile);
RHDrawImageInRect(rightEdgeFill, rightEdgeFillRect, op, fraction, shouldTile);
RHDrawImageInRect(bottomLeftCorner, bottomLeftCornerRect, op, fraction, NO);
RHDrawImageInRect(bottomEdgeFill, bottomEdgeFillRect, op, fraction, shouldTile);
RHDrawImageInRect(bottomRightCorner, bottomRightCornerRect, op, fraction, NO);
}
Lastly We Add a Category on NSImage
The final step in adding resizableImage methods to NSImage is a simple passthrough category to return new RHResizableImage instances.
@implementation NSImage (RHResizableImageAdditions)
...
-(RHResizableImage*)resizableImageWithCapInsets:(RHEdgeInsets)capInsets{
RHResizableImage *new = [[RHResizableImage alloc] initWithImage:self capInsets:capInsets];
return arc_autorelease(new);
}
...
@end
Wrapping Up
Phew, that was a bunch of code! But fear not, sources are available in my RHAdditions collection on GitHub.
You might also find my latest app Nine Parts useful. It makes it easy to preview and slice resizable images for iOS, OS X and the web.
As always, any and all feedback is greatly appreciated.
Follow me on Twitter @heardrwt.