Shared posts

07 Jul 16:53

Making NSImages Resizable

by Richard

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.

04 Jul 05:37

Asimov’s Children

by Jon
Keith.ray

made me laugh.

Asimov’s Children

Howdy folks! I’m going to spend the next few comics saying hello to some of my favorite science fiction authors. Today we’re saying hi to Isaac Asimov. Hello, Isaac!

The new Scenes From A Multiverse book, BUSINESS ANIMALS, is available for pre-order! Order before June 14th and you can choose to get your book signed and sketched and it will come with a free MYSTERY GIFT! You can also order a discount bundle with the first book and get ‘em both scribbled in at the same time. It is up to you!

This is my major release this year and I really, really hope it does well and that you guys enjoy it. If you love SFAM and want to support the strip, please consider pre-ordering a copy or three so I can keep doing this job and drawing comics for you. Thanks!