Click here to Skip to main content
Click here to Skip to main content

Irregularly Shaped Buttons

By , 10 Sep 2010
 
IrregButtonBig.png

Forewarning: I am putting this up because it is good code, and a neat illustration of Objective-C's ability to descend into the depths of C some low-level bit-bashing.

If you need to implement irregularly shaped buttons, this is for you. Download the code, and the article will help you understand it. If you want to learn from it, you will need to pick through the code, and only then will my preamble be of use to you.

I followed with interest Jeff LaMarche's blog posts on creating irregularly shaped buttons:

and the follow up:

The comments in the follow-up contain some good suggestions:

  • use of calloc instead of malloc -- the code as is buggy, as malloc fails to initialize elements to 0
  • use of a bitArray instead of a byteArray to reduce memory usage
  • and initial bounding box check, to reduce computation

The comments also point out that the code leaks memory.

I have put together a third generation to this project, which:

  • implements all of the above suggestions
  • fixes the memory leaks
  • overrides pointInside : withEvent : rather than hitTest : withEvent : (see documentation for hitTest)
  • removes the lazy load, instead performing initialization when the UIButton object is initialized, or when its image is changed
  • allows you to set a threshold alpha level, beyond which presses will not register

This work involved a complete restructuring -- rather than have one low-level routine grab the Alpha data, creating memory for it, before throwing it to another one, etc., I have packed all of the low-level stuff into a single routine that emits an auto released NSData object. This routine is careful to free all memory it allocates.

I took out the lazy load -- it is a bad idea to perform intensive calculation the first time an object is pressed -- this gives an inconsistent UI experience, which is exasperating. Also, by using a bitArray, the memory footprint is reduced by eight times, so there is less point in trying to save memory. You might think it would be enough to override the setImage and setBackgroundImage methods of UIButton. However, if it is created from a NIB, these methods do not get invoked. It must be that the iVar gets set directly.

I noticed by setting breakpoints that pointInside : withEvent : gets hit three times for each press. This is a mystery to me -- even looking at the call stack doesn't switch on the light bulb. However, it reinforces the importance of performing minimal processing to test whether the point will register a hit.

Overall, this project is a nice little exercise in memory management and optimization, and shows off the ability of Objective-C to harness the power of C. If you pick through the code, you can see it also gives some insight as to the nature of bitmaps and bitmap contexts.

//
//  TestViewController.h
//  Test
//
//  Pi

@interface TestViewController : UIViewController 
{ }

- (IBAction) buttonClick : (id) sender;

@end

//
//  TestViewController.m
//  Test
//
//  Pi

#import "TestViewController.h"

@implementation TestViewController

- (IBAction) buttonClick : (id) sender
{
    NSLog(@"Clicked:%@", sender);
}

- (void) dealloc 
{
    [super dealloc];
}

@end

//
//  ClickThruButton.h
//  Test
//
//  Pi

@class AlphaMask;

@interface clickThruButton : UIButton 
{
    @private AlphaMask* _alphaMask;
}

@end

//
//  ClickThruButton.m
//  Test
//
//  Pi

#import "clickThruButton.h"
#import "AlphaMask.h"

@interface clickThruButton ()

@property (nonatomic, retain) AlphaMask* alphaMask;

- (void) myInit;
- (void) setMask;

@end

@implementation clickThruButton

@synthesize alphaMask = _alphaMask;

/*
 To make this object versatile, we should allow for the possibility 
 that it is being used from IB, or directly from code. 
 By overriding both these functions, we can ensure that 
 however it is created, our custom initializer gets called.
 */
#pragma mark init
// if irregButtons created from NIB
- (void)awakeFromNib
{
    [super awakeFromNib];
    [self myInit];    
}

// if irregButtons created or modified from code...
- (id) initWithFrame: (CGRect) aRect
{
    self = [super initWithFrame: aRect];
    if (self) 
        [self myInit];    
    return self;    
}

- (void) myInit
{
    // Set so that any alpha > 0x00 (transparent) sinks the click
    uint8_t threshold = 0x00;
    self.alphaMask = [[AlphaMask alloc]  initWithThreshold: threshold]; 
    [self setMask];
}

#pragma mark if image changes...
- (void) setBackgroundImage: (UIImage *) _image 
                   forState: (UIControlState) _state
{
    [super setBackgroundImage: _image 
                     forState: _state];
    [self setMask];
}

- (void) setImage: (UIImage *) _image 
         forState: (UIControlState) _state
{
    [super setImage: _image 
           forState: _state];
    [self setMask];
}

#pragma mark Set alphaMask
/*
 Note that we get redirected here from both our custom initializer 
 and the image setter methods which we have overridden.
 
 We can't just override the setters -- if the object is loading from a 
 NIB these methods don't fire. Clearly it must set the iVars directly.
 
 This method should get invoked every time the buttons image changes.
 Because it needs to extract, process and compress the Alpha data, 
 in a way that our hit tester can access quickly.
 */
-(void) setMask
{
    UIImage *btnImage = [self imageForState: UIControlStateNormal];
    
    // If no image found, try for background image
    if (btnImage == nil) 
        btnImage = [self backgroundImageForState: UIControlStateNormal];
    
    if (btnImage == nil)  
    {
        self.alphaMask = nil;
        return ;
    }
    
    [self.alphaMask  feedImage: btnImage.CGImage];
}

#pragma mark Hit Test!
/* override pointInside:withEvent:
 Notice that we don't directly override hitTest. If you look at the 
 documentation you will see that this button's PARENT's hit tester 
 will check the pointInside methods of one of its children.
 */
- (BOOL) pointInside : (CGPoint) p  
           withEvent : (UIEvent *) event
{
    // Optimization check -- bounding box
    if (!CGRectContainsPoint(self.bounds, p))
        return NO;
    
    // Checks the point against alphaMask's precalculated bit array, 
    // to determine whether this point is allowed to register a hit
    bool ret = [self.alphaMask  hitTest: p];
    
    // If yes, send ' yes ' back to the parents hit tester, 
    // which will be one level up the call stack.  
    // So in this example, the parent will be the view, 
    // and it will check through all of its children until 
    // it finds one that responds with ' yes '
    return ret;
}

#pragma mark dealloc
- (void)dealloc
{
    [self.alphaMask release];
    [super dealloc];
}
@end

//
//  imageHelper.h
//  test
//
//  Pi

@interface  AlphaMask : NSObject
{ 
@private
    uint8_t alphaThreshold;
    size_t imageWidth;
    NSData* _bitArray;
}

- (id) initWithThreshold: (uint8_t) t;

- (void) feedImage: (CGImageRef) img;

- (bool) hitTest: (CGPoint) p;

// Private methods and properties defined in the .m

@end

//
//  imageHelper.m
//  test
//
//  Pi

#import "AlphaMask.h"

// Private methods discussion here:
// http://stackoverflow.com/questions/172598/
//best-way-to-define-private-methods-for-a-class-in-objective-c
//Objective-C doesn't directly support private methods. Using an 
// empty category is an acceptably hacky way to achieve this effect.
@interface  AlphaMask () // <-- empty category

// each bit represents 1 pixel: will hold Yes/No for click-thru, 1=hit 0=click-thru
@property (nonatomic, retain) NSData* bitArray;

// note + means STATIC method
+ (NSData *) calcHitGridFromCGImage: (CGImageRef) img
           alphaThreshold: (uint8_t) alphaThreshold_ ;
@end

@implementation AlphaMask

@synthesize bitArray = _bitArray;

#pragma mark Init stuff
/*
 See below for a more detailed discussion on alphaThreshold.  
 Basically if you set it to 0, the hit tester will only 
 pass through pixels that are 100% transparent
 
 Setting it to 64 would pass through all pixels 
 that are less than 25% transparent
 
 255 is the maximum. Setting to this, the image cannot 
 take a hit -- everything passes through.
 */
- (id) initWithThreshold: (uint8_t) alphaThreshold_
{
    self = [super init];
    if (!self) 
        return nil;    

    alphaThreshold = alphaThreshold_;
    self.bitArray = nil;
    imageWidth = 0;
    
    return [self init];
}

- (void) feedImage: (CGImageRef) img
{
    self.bitArray = [AlphaMask calcHitGridFromCGImage: img
                        alphaThreshold: alphaThreshold];
    
    imageWidth = CGImageGetWidth(img);
}

#pragma mark Hit Test!
/*
 Ascertains, through looking up the relevant bit in our bit array 
 that pertains to this pixel, whether the pixel should take the hit
 (bit set to 1) or allow the click to pass through (bit set to 0).
 In order to minimize overhead, I am playing with C pointers directly.
 
 Note: for some reason, iOS seems to be hit testing each object
 three times -- which is bizarre, and another good reason for 
 spending as little time as possible inside this function.
 */
- (bool) hitTest: (CGPoint) p
{
    const uint8_t c_0x01 = 0x01; 
    
    if (!self.bitArray)
        return NO;
    
    // location of first byte
    uint8_t * pBitArray = (uint8_t *) [self.bitArray bytes];
    
    // the N'th pixel will lie in the n'th byte (one byte covers 8 pixels)
    size_t N = p.y * imageWidth + p.x;
    size_t n = N / (size_t) 8;
    uint8_t thisPixel = *(pBitArray + n) ;
    
    // mask with the bit we want
    uint8_t mask = c_0x01 << (N % 8);
    
    // nonzero => Yes absorb HIT, zero => No - click-thru
    return (thisPixel & mask) ? YES : NO;
}

#pragma mark Extract alphaMask from image!
// Constructs a compressed bitmap (one bit per pixel) that stores for each pixel
//     whether that pixel should accept the hit, or pass it through.
// If the pixels alpha value is zero, the pixel is transparent
// if the pixels alpha value > alphaThreshold, the corresponding bit is set to 1, 
//     indicating that this pixel is to receive a hit
//Note that setting alphaThreshold to 0 means that any pixel that is not 
//     100% transparent will receive a hit
+ (NSData *) calcHitGridFromCGImage: (CGImageRef) img
                     alphaThreshold: (uint8_t) alphaThreshold_
{
    CGContextRef    alphaContext = NULL;
    void *          alphaGrid;
    
    size_t w = CGImageGetWidth(img);
    size_t h = CGImageGetHeight(img);
    
    size_t bytesCount = w * h * sizeof(uint8_t);
    
    // allocate AND ZERO (so can't use malloc) memory for alpha-only context
    alphaGrid = calloc (bytesCount, sizeof(uint8_t));
    if (alphaGrid == NULL) 
    {
        fprintf (stderr, "calloc failed!");
        return nil;
    }
    
    // create alpha-only context
    alphaContext = CGBitmapContextCreate 
    	(alphaGrid, w, h, 8,   w, NULL, kCGImageAlphaOnly);
    if (alphaContext == NULL)
    {
        free (alphaGrid);
        fprintf (stderr, "Context not created!");
        return nil;
    } 
    
    // blat image onto alpha-only context
    CGRect rect = {{0,0},{w,h}}; 
    CGContextDrawImage(alphaContext, rect, img); 
    
    // grab alpha-only image-data
    void* _alphaData = CGBitmapContextGetData (alphaContext);
    if (!_alphaData)
    {
        CGContextRelease(alphaContext); 
        free (alphaGrid);
        return nil;
    }
    uint8_t *alphaData = (uint8_t *) _alphaData;
    
    // ---------------------------
    // compress to 1 bit per pixel
    // ---------------------------
        
    size_t srcBytes = bytesCount;
    size_t destBytes = srcBytes / (size_t) 8;
    if (srcBytes % 8)
        destBytes++;
    
    // malloc ok here, as we zero each target byte
    uint8_t* dest = malloc (destBytes);
    if (!dest) 
    {
        CGContextRelease(alphaContext); 
        free (alphaGrid);
        fprintf (stderr, "malloc failed!");
        return nil;
    }
    
    size_t iDestByte = 0;
    uint8_t target = 0x00, iBit = 0, c_0x01 = 0x01;
    
    for (size_t i=0; i < srcBytes; i++) 
    {
        uint8_t src = *(alphaData++);
        
        // set bit to 1 for 'takes hit', leave on 0 for 'click-thru'
        // alpha 0x00 is transparent
        // comparison fails famously if not using UNSIGNED data type
        if (src > alphaThreshold_)
            target |= (c_0x01 << iBit);
        
        iBit++;
        if (iBit > 7) 
        {
            dest[iDestByte] = target;
            target = 0x00;
            
            iDestByte++;
            iBit = 0;
        }
    }
    
    // COPIES buffer
    // is AUTORELEASED!
    // http://developer.apple.com/mac/library/documentation/Cocoa/Conceptual/
    // MemoryMgmt/Articles/mmRules.html#//apple_ref/doc/uid/20000994-BAJHFBGH
    NSData* ret = [NSData dataWithBytes: (const void *) dest 
                                 length: (NSUInteger) destBytes ];
    
    CGContextRelease (alphaContext);
    free (alphaGrid);
    free (dest);
    
    return ret;
}

@end
IrregButton.png

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

Ohmu
Architect
United Kingdom United Kingdom
Member
I like to create and invent things. Recent projects include: A novel computer interface (Google for JediPad), a new musical system (www.toneme.org), a speech typer (http://spascii.wikispaces.com/)
 
Currently I am making some innovative musical instruments for iPhone/iPad
 
PS Currently stranded in Thailand with no money! Great! So, if you like my articles, please consider putting some coins in the box. If I have to sell my MacBook and teach in a school, that means No More Articles! PayPal is sunfish7&gmail!com. Steve Jobbs -- if you're reading this, pay me to fix your documentation.

Sign Up to vote   Poor Excellent
Add a reason or comment to your vote: x
Votes of 3 or less require a comment

Comments and Discussions

 
You must Sign In to use this message board.
Search this forum  
    Spacing  Noise  Layout  Per page   
QuestionProblem when resizing the view [modified]memberkamebkj17 Jan '12 - 0:29 
Hi, this post is very useful and helpful, however i'm having some problems when resizing the view.
Since I'm adding button on imageView, and the imageView is in a scrollView so I can zoom in/out, zooming would let this approach fail.
Can you please help on this? Thanks in advance.

modified 17 Jan '12 - 6:55.

GeneralBug when creating from code.memberMember 76996633 Mar '11 - 12:46 
In your setMask method you have "self.alphaMask = nil;" so if you create the button with code you kill your alpha mask so it can never be set again. That is probably why the previous post was having issues.
GeneralRe: Bug when creating from code.memberMember 81471799 Aug '11 - 3:54 
I should have read the comments first - I had this problem too!
Yes, there is no need to set the mask to nil, and it stops it from working.
I commented out that line, and added a check in the hit test method to return YES if the point was within the bounds, and there was no background or foreground image set.
I also modified the setImage methods to only update the mask when images are added for the normal control state (to prevent firing multiple times when adding images for different control states).
 
Nice bit of code though - very useful!
GeneralStill seeing Memory Leaksmemberaxeva9 Oct '10 - 9:22 
The button works great -- thank you.
 
I'm still seeing a memory leak, however. Instrument shows two actually:
 
The first is in the calcHitGridFromCGImage method of AlphaMask.m. The Leaks tool in Instruments shows it here:
 
	// COPIES buffer
	// is AUTORELEASED!
	// http://developer.apple.com/mac/library/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmRules.html#//apple_ref/doc/uid/20000994-BAJHFBGH
	NSData* ret = [NSData dataWithBytes: (const void *) dest 
					length: (NSUInteger) destBytes ];   // <<<< LEAK
	
	CGContextRelease (alphaContext);
	free (alphaGrid);
	free (dest);
	
	return ret;
}
 
The second one is in the myInit method of clickThruButton.m:
 
- (void) myInit
{
	// Set so that any alpha > 0x00 (transparent) sinks the click
	uint8_t threshold = 0x00;
 
	self.alphaMask = [[AlphaMask alloc]  initWithThreshold: threshold];  // <<<< LEAK
	[self setMask];
}
 
In my case, I'm using the clickThruButton on a standard view inside a UINavigationController. Not sure if the UINavigationController adds to the complexity -- perhaps that's where the trouble lies...
GeneralRe: Still seeing Memory Leaksmemberaxeva9 Oct '10 - 12:31 
FYI, there were some suggested fixes for this on Stack Overflow:
 
Memory Leaks in Irregularly Shaped UIButtons
GeneralRe: Still seeing Memory LeaksmemberGoffredo Marocchi12 Nov '10 - 4:26 
The CGContext flip fix mentioned online
 
// create alpha-only context
alphaContext = CGBitmapContextCreate (alphaGrid, w, h, 8, w, NULL, kCGImageAlphaOnly);
if (alphaContext == NULL)
{
free (alphaGrid);
fprintf (stderr, "Context not created!");
return nil;
}

// Flip context
CGContextTranslateCTM(alphaContext, 0, h);
CGContextScaleCTM(alphaContext, 1.0, -1.0);
 
might be useful due to the way images are loaded, but further tests should be conducted... it might not do anything at all or it might worsen things... I have not checked yet.
 
An issue, or at least something people should be warned about, is that the code does not work as advertised if the UIButton is stretched. If you take a 100x100 icon and you use a 50x50 frame you will not get accurate hit detection. I am not sure how this code behaves on the iPhone 4 though, but it works well on the iPad Smile | :) .
GeneralCan't get this to work, please help!!memberella_romanos20 Sep '10 - 5:31 
Hi,
 
Sorry, I'm not a pro at iphone development, but I can't seem to get your code to work. I was using Jeff LaMarche's code to do my buttons but that kept crashing due to memory leaks (was fine running through the simulator, but crashed consistenly on a device).
 
So I am trying to use yours in exactly the same way, but the buttons don't work.
 
I just copied your clickThruButton .m and .h, and AlphaMask .m and .h, then import the class and declare a button as: clickThruButton *myBtn = [[clickThruButton buttonWithType:UIButtonTypeCustom] retain];
 
I get no errors but the buttons don't seem to pick up when they have been hit.
 
Either I'm doing something very stupid (which is possible!!) or I was wondering whether the fact that your class overrides pointInside rather that hittest might be causing the problem?
 
Thanks!
GeneralRe: Can't get this to work, please help!!membercsi957 Oct '10 - 16:56 
Ella,
 
Your code shows you creating the button, but not attaching any events to it.
 
Do you have an IBAction setup?
 
Are you making the connection between the button and the action? Something like...
 

[myBtn addTarget:self action:@selector(myButtonTap:) forControlEvents: UIControlEventTouchUpInside];

GeneralFunny Music Idea for IphonememberBlake Miller10 Sep '10 - 8:14 
I do not knwo if the IPhone can detect 'movement' or not. If so, write a small music aplciation that is like a 'shaker'. When you shake the phone it makes different musical notes. Shake faster, more notes, hold upside down or sideways maybe? Different notes again. If you have ever watched a little kid, they love to shake things and listen to the different noises they make.

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Permalink | Advertise | Privacy | Mobile
Web04 | 2.6.130523.1 | Last Updated 10 Sep 2010
Article Copyright 2010 by Ohmu
Everything else Copyright © CodeProject, 1999-2013
Terms of Use
Layout: fixed | fluid