Filed under Mac

Making NSImages Resizable

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.

image

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.

image

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, using NSDrawNinePartImage();.
  • 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.

RHIntervalTree

An Objective-C implementation of a Centred Interval Tree.

RHIntervalTree provides an Objective-C wrapper around an internal C++ Interval Tree implementation by Erik Garrison.

Overview

An interval tree can be used to efficiently find a set of numeric intervals overlapping or containing another interval, for example a view containing overlapping calendar events for a given day or week.

They can also be used for windowing queries, for instance, to find all roads on a computerised map inside a rectangular viewport, or to find all visible elements inside a three-dimensional scene.

See Wikipedia for more info.

Grab It Now

You can find RHIntervalTree over on GitHub.

RHAdditions

I finally got around to posting a bunch of my Objective-C categories and additions that have served me well over the years. Hopefully then can serve you too!

(useful && included) things

  • UIColor interpolation.
  • UIImage pixel access / comparison logic.
  • UIImage resizing.
  • NSThread performBlock methods.
  • Launch At Login via SMLoginItemSetEnabled().
  • NSImage -> (PNG / JPEG / GIF) representations.
  • UIView / NSView snapshotting.
  • NSString URL encoding.
  • Support building both with and without ARC.
  • NSWindow smooth resizing.
  • NSObject class-dump like logging.
  • UIView completed action badge.
  • Debug logging macros.
  • ROT13 (Yea I know!).
  • and much more…

You can find RHAdditions over on GitHub.

RHStatusItemView

Do you every find yourself wishing you could right click on an apps status icon? I sure do! (I’m looking at you Skitch, and you Twitterific.) Strangely enough while adding a menu to an NSStatusItem is easy, making it show on a right click is not!

Introducing RHStatusItemView.

An NSStatusItem view that supports handling both left and right click actions, menus and showing an image / alternateImage pair.

You can find RHStatusItemView over on GitHub.

BringBackDelete Safari 6.0 extension

A Simple Safari 6.0+ extension that re-enables “back to previous page” navigation when you press the delete key.

  • It won’t steal focus from text / input fields.
  • It won’t steal focus from flash plugins etc.

This functionality was removed in Safari 6.0 which made me sad. Hopefully someone else also finds this useful.

Find it on GitHub

Tagged , , , ,

RHPreferences

A simple and easy Preferences window controller with multiple tabs for your next Mac application.

A Mac OS X window controller subclass that makes it easy to provide a standard Preferences window with multiple tabs in your application.

It also provides:

  • Auto resizing between different sized tab views (With animation)
  • Custom NSToolbarItem support
  • Persistance of the last used tab
  • Support for placeholder NSToolbarItems (eg NSToolbarFlexibleSpaceItemIdentifier & NSToolbarShowFontsItemIdentifier)

Check it out over on GitHub.