読者です 読者をやめる 読者になる 読者になる

iOSアプリ開発の逆引き辞典

iPhone/iPadで使えるアプリ開発のTipsをまとめてみました

画像の透明な部分を無視するUIButtonを作る

2月からかなり久しぶりにiPhoneアプリを作るお仕事をしています。

月曜日にデザインをデザイン仕様書をもらいました。どんなアプリかというと、日本地図が表示されていて「関東地域」の部分をタップすると「関東地域」が拡大表示されて、さらに「東京都」をタップすると東京との情報が表示されるようなアプリです*1

iPhoneを使っている方なら「ウェザーニュース タッチ」みたいな感じと言えば分かるでしょうか。下図のように日本地図から黄色の枠線で囲まれた「近畿」の部分をタップすると、より詳細な地図が表示されます。

f:id:ch3cooh393:20140211164507p:plain

「北海道」「東北」「関東」……と、ViewControllerUIButtonを重ねてペタペタ貼って、ボタンに画像を貼り付けて位置調整して完了っ♪

画像の透明部分が無視されないUIButton

……と安易に考えていたのですが、ところがどっこいUIButtonは画像を設定して、(画像の)透明部分をタップしてもUIButtonは自分がタップされたと反応してしまうようなのです。日本地図のような非矩形画像の集合体の場合、一番前面に位置しているボタンが反応してしまいます。

ロボットの「顔」の画像を用意しました。このロボットを使ってどういうことかを説明したいと思います。

画像の真ん中にロボットの「顔」が描かれており、顔の周りはアルファ値ゼロの透過色です。

f:id:ch3cooh393:20140211162715j:plain

ロボットの「顔」を「手」「足」「胴体」とくっつけました。それぞれUIButtonを使っていてタップすると場所に応じた反応をさせたいと考えています。

しかし、このままだと前述した日本地図の例のように、ユーザーが「胴体」をタップしているのにも関わらず、「顔」の周りの透明部分が広いためUIButtonが反応してしまい、「顔」をタップした時のイベントが発生してしまいます。

f:id:ch3cooh393:20140211162729j:plain

絶対にこの現象で悩んだ人がいると思ったので検索してみたのですが、残念ながら発見する事ができませんでした。

解決

やりたくないと思って後回しにしていましたが、どのようにして解決するのか考えてみましょう。

パッと思いつくのが、ヒットテストを使う方法です。ヒットテストとは、ユーザーが入力したイベントに対して、重なっている複数のViewに対してフレームワークがヒットテストをおこない、どのオブジェクトが反応するかを決定する方法です。

UIButtonの基底クラスのUIViewには(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)eventというメソッドが用意されています。このメソッドでは反応しない場合はnilを返し、反応する場合にはselfを返します。

hitTest:CGPoint:UIEvent *メソッドをオーバーライドをする必要があるため、UIButtonを継承したSBButton クラスを作りました。

SBButton.h

ヘッダーでは何も追加せず、UIButtonを継承しているだけです。

#import <UIKit/UIKit.h>

@interface SBButton : UIButton

@end

SBButton.m

実装側では当該のhitTest:CGPoint:UIEvent *メソッドを追加しました。何をやっているかはコメントを見て頂ければよいかと思います :-D

#import "SBButton.h"
#import "UIImage+PixelDataAdditions.h"

@interface SBButton() {
}

@end

#pragma mark - Implementation

@implementation SBButton

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
    }
    return self;
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (!CGRectContainsPoint(self.bounds, point)) {
        return nil;
    }

    // UIButtonに設定されている画像からアルファ値を取り出す
    UIImage *image = self.imageView.image;
    NSData *data = [image alphaData];
    
    // スケールを取得する
    // Retina Displayの場合は
    CGFloat scale = 1.0;
    UIScreen *screen = [UIScreen mainScreen];
    if ([screen respondsToSelector:@selector(scale)]) {
        scale = [screen scale];
    }

    // タップした位置が透明であれば、下のViewにイベントを受け流す
    NSUInteger index = (point.x + (point.y * image.size.width)) * scale;
    if (((unsigned char *)[data bytes])[index] == 0) {
        return nil;
    }

    return self;
}

@end

(おまけ)UIImageでアルファ値を取得するカテゴリ

UIImage+PixelDataAdditions.h

@interface UIImage (PixelDataAdditions)

- (NSData *)alphaData;

@end

UIImage+PixelDataAdditions.m

#import "UIImage+PixelDataAdditions.h"

@implementation UIImage (PixelDataAdditions)

// アルファ値を取得する
- (NSData *)alphaData
{
    CGImageRef imageRef = self.CGImage;

    CFDataRef dataRef = CGDataProviderCopyData(CGImageGetDataProvider(imageRef));
    NSData* pixelData = (__bridge NSData*) dataRef;
    unsigned char* buffer = (unsigned char *)[pixelData bytes];
    
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    size_t pixelSize = CGImageGetBitsPerPixel(imageRef) / 8;

    size_t size = width * height;
    unsigned char array[size];
    for (int i = 0; i < size; i++) {
        unsigned char alpha = * (buffer + (i * pixelSize + 3));
        array[i] = alpha;
    }

    NSData* data = [NSData dataWithBytes:(const void *)array
                                  length:sizeof(unsigned char) * size];

    CFRelease(dataRef);
    return data;
}

@end

*1:実際には全然違いますが……