Non-Rectangular Buttons on iOS

sample remote from Yahoo! Connected TV iOS SDKOne of the projects I worked on last year was the iOS SDK for Yahoo! Connected TV. Along with the SDK, Yahoo! wanted to ship an example app that demonstrated use of the SDK. Take a look at the screenshot to the right. See anything a little out of the ordinary?

Several of the buttons, especially the colored ones along the bottom half of the directional pad, are not rectangular.

the frame of the green button, showing how it overlaps the down and rewind buttonsIt doesn’t matter if we’re developing on the Mac or iOS. Views are rectangular and they either gobble a click/tap or they don’t. Take a look at the green button. The blue outline shows the frame of the view. It overlaps two other buttons: down and rewind. Especially on iOS, where a fingertip is an imprecise input method, a user will find it very frustrating if a tap on down was instead interpreted as green.

The solution is quite straightforward on iOS: create a subclass of UIButton and override -pointInside:withEvent:.

- (BOOL) pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    /* Surprisingly, this method is called even when point has a negative
     * component and is obviously outside of self.bounds. */
 
    if (CGRectContainsPoint(self.bounds, point))
    {
        /* The point is within our bounds. If a mask exists, check the mask
         * for an allowed tap. If there is no mask, let the superclass
         * (UIButton) do the work. */
 
        if (mask != NULL)
        {
            return mask[(NSUInteger)point.y * maskWidth + (NSUInteger)point.x];
        }
        else
        {
            return [super pointInside:point withEvent:event];
        }
    }
    else
    {
        return NO;
    }
}

pointInside:withEvent: is called by hitTest:withEvent:, once for each subview. If a subview returns NO, then its branch of the hierarchy is ignored. By making pointInside:withEvent: sensitive to only the part of the image that is the button, it’s possible to ignore events for taps on a part that doesn’t.

The key is building a mask. Here, it is a two dimensional BOOL array. If mask is not NULL, the x and y of the point is used to index into the array and return the value there. If mask is NULL for any reason, the code simply falls back to the superclass’s behavior.

One way to build the mask is to look at the alpha channel of the image.

- (void) buildMask
{
    if (mask)
    {
        free(mask);
        mask = NULL;
    }
 
    CGImageRef image = [self imageForState:UIControlStateNormal].CGImage;
    if (image == nil)
    {
        return;
    }
 
    NSUInteger width = CGImageGetWidth(image);
    NSUInteger height = CGImageGetHeight(image);
    uint32_t *pixels = malloc(width * height * 4);
 
    if (pixels != NULL)
    {
        /* Provided that memory for the temporary 32-bit pixel array was
         * available, draw the CGImage into that memory. */
 
        CGColorSpaceRef colorSpaceRef = CGColorSpaceCreateDeviceRGB();
        CGContextRef context = CGBitmapContextCreate(pixels, width, height,
                                                     CGImageGetBitsPerComponent(image),
                                                     width * 4, colorSpaceRef,
                                                     kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Host);
 
        CGContextSetBlendMode(context, kCGBlendModeCopy);
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), image);
        CGContextRelease(context);
        CGColorSpaceRelease(colorSpaceRef);
 
        mask = malloc(width * height * sizeof(BOOL));
        maskWidth = width;
        if (mask != NULL)
        {
            for (NSUInteger y = 0; y < height; ++y)
            {
                for (NSUInteger x = 0; x < width; ++x)
                {
                    /* RGBA: high 24 bits are color, low 8 bits are alpha.
                     * Less than 50% alpha is the untapped part of the image. */
                    mask[y * width + x] = (pixels[y * width + x] & 0xFF) > 128;
                }
            }
        }
        else
        {
            NSLog(@"Unable to allocate memory, will fall back to UIButton behavior.");
        }
 
        free(pixels);
    }
    else
    {
        NSLog(@"Unable to allocate memory, will fall back to UIButton behavior.");
    }
}
 
- (void) setImage:(UIImage *)image forState:(UIControlState)state
{
    [super setImage:image forState:state];
    [self buildMask];
}

The subclass overrides setImage:forState: so that when the image is set or changed, the mask is regenerated. To use a non-rectangular button within Interface Builder, we need to also override initWithCoder:.

- (id) initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self)
    {
        [self buildMask];
    }
 
    return self;
}

If you don’t want to use the alpha channel, another option is to provide an explicit mask image. Masks are monochrome bitmaps, where white indicates the visible area. Building the mask array from a mask image is very similar to using the alpha channel. The only difference is what part of the pixel data is analyzed.

- (void) setMaskImage:(UIImage *)maskImage
{
    if (mask)
    {
        free(mask);
        mask = NULL;
    }
 
    if (maskImage == nil)
    {
        return;
    }
 
    CGImageRef image = maskImage.CGImage;
    NSUInteger width = CGImageGetWidth(image);
    NSUInteger height = CGImageGetHeight(image);
    uint32_t *pixels = malloc(width * height * 4);
 
    if (pixels != NULL)
    {
        /* Provided that memory for the temporary 32-bit pixel array was
         * available, draw the CGImage into that memory. */
 
        CGColorSpaceRef colorSpaceRef = CGColorSpaceCreateDeviceRGB();
        CGContextRef context = CGBitmapContextCreate(pixels, width, height,
                                                     CGImageGetBitsPerComponent(image),
                                                     width * 4, colorSpaceRef,
                                                     kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Host);
 
        CGContextSetBlendMode(context, kCGBlendModeCopy);
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), image);
        CGContextRelease(context);
        CGColorSpaceRelease(colorSpaceRef);
 
        mask = malloc(width * height * sizeof(BOOL));
        maskWidth = width;
        if (mask != NULL)
        {
            for (NSUInteger y = 0; y < height; ++y)
            {
                for (NSUInteger x = 0; x < width; ++x)
                {
                    /* RGBA: high 24 bits are color. Anything other than black
                     * is tappable. */
                    mask[y * width + x] = (pixels[y * width + x] & 0xFFFFFF00) != 0;
                }
            }
        }
        else
        {
            NSLog(@"Unable to allocate memory, will fall back to UIButton behavior.");
        }
 
        free(pixels);
    }
    else
    {
        NSLog(@"Unable to allocate memory, will fall back to UIButton behavior.");
    }
}