ボタンのタップ反応エリアの拡大方法

ボタンのタップ反応エリアの拡大方法

アプリを作っていると、実機で触ってみたときに「ボタンが押しにくい」とか「タップのあたり判定がせまい」とかってあるかと思います。たいていの場合はボタンに使っている画像が小さい等が原因だったりします。このような場合の対処方法があらためて探してみても意外と見つからなかったので今更感がありますが普段僕がやっている方法を紹介します。もっと良い方法があれば教えてください。

追記: @k_katsumi さんに指摘頂いた内容を追記しました。

対処方法

1. UI デザイン自体を再考

そもそも論ですが、HIG でも 44pt x 44pt を基準にすることが推奨されているわけですから押しにくい UI デザイン自体を直すのがユーザーのためです。実際にはひっくり返すことになるのでなかなか出来ない話ですw

2. 単純に大きさを大きくする

全然対処方法でもないですねw ビジュアル上イマイチになってしまうことが多いと思います。

3. ボタンに使っている画像を大きくする

「i」ボタンみたいなボタンを実装する時、デザイナーさんは「i」のデザインぎりぎりで切り出したりしている事がまれにあります。それをそのままボタンに組み込むと

  • 「i」アイコンの見た目の大きさ = ボタンの大きさ

となってしまいタップしづらくなります。「i」のデザイン自体の大きさは変えずに周りに透明で埋めた画像で再度切り出してもらうと一番簡単です。大きさや配置する場所の条件によってはレイアウト自体も簡単になります。

4. ボタン自身のあたり判定領域を拡大

ここからはプログラマ的話です。透明ピクセルで誤魔化す方法がとれない場合、ボタンの見かけ上の大きさを変えずにボタンのあたり判定を大きくします。ここではボタン自身が自分のサイズよりもあたり判定領域を広くするアプローチを説明します。

方法は UIView- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event をオーバーライドします。pointInside:withEvent:- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event の中で実行されます。pointInside:withEvent:YES を返すとその View 自身にタップイベントが来ます。

まず、UIButton のサブクラスを作ります。pointInside:withEvent: メソッドを以下のような感じにオーバーライドします。

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    CGRect rect = self.bounds;
    // 自身の bounds を Insets 分大きさを変える
    rect.origin.x += self.tappableInsets.left;
    rect.origin.y += self.tappableInsets.top;
    rect.size.width -= (self.tappableInsets.left + self.tappableInsets.right);
    rect.size.height -= (self.tappableInsets.top + self.tappableInsets.bottom);
    // 変更した rect に point が含まれるかどうかを返す
    return CGRectContainsPoint(rect, point);
}

tappableInsets はあたり判定を外から拡縮できるように追加したプロパティで型は UIEdgeInsets です。上下左右に当たり判定を 20pt 広がたい場合には

flexibleButton.tappableInsets = UIEdgeInsetsMake(-20, -20, -20, -20);

のような感じで指定します。

この方法ではボタン自身が当たり判定を広げているのでサブクラス化の手間はかかりますがボタンクラスの使い回しがしやすいのが利点です。

4. ボタンの親ビューがあたり判定を行う方法

わざわざボタンのサブクラス化が面倒な時且つボタン群を乗せた View のサブクラス(例えばおれおれツールバー風なもの)別途用意している時はこの方法が楽です。

ボタンを subview として持っている View クラスで - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event を以下のような感じでオーバーライドします。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // "hidden", "userInteractionEnabled", "alpha" の値を考慮する
    if (self.button.isHidden || self.button.userInteractionEnabled == NO || self.button.alpha < 0.01) {
        return [super hitTest:point withEvent:event];
    }
    
    // button のあたり判定 rect を作成
    CGRect rect = CGRectInset(self.button.frame, -20, -10);
    // あたり判定 rect 内であれば、button を返し、button にイベントを受けられるようにする
    if (CGRectContainsPoint(rect, point)) {
        return self.button;
    }
    
    // button のあたり判定外だったら従来の挙動に任せる
    return [super hitTest:point withEvent:event];
}

追記: リファレンスに記載されいてるように hidden, userInteractionEbabled, alpha の値を考慮する処理を追加しました。これが抜けていると hidden にしている button にもイベントが飛んでしまうためです。

5. 画像サイズはそのままでボタンを大きくする(追記)

例に挙げたアイコンを乗せただけのボタンの場合、setImage:forState: でアイコン画像をボタンにセットし、ボタンの frame を大きくすると見た目上(アイコン)の大きさを変えずにボタンの面積を大きくすることができます。比較的簡単に実装できます。

懸念点としては

  • 表現したいボタンによってはこの方法は使えない(見た目上も大きくなってしまう)
  • コードでレイアウトする場合に違和感がでる(origin がマイナスになるとか)

があるのであまり使わないかもしれません。

追記: タップされた位置が SuperView の外の場合

タップされた位置が button の superView の外の場合には上記の方法のいずれの方法もイベントがとれません。検証はしていませんが、button の superView 側の hitTest:withEvent: でイベントをフックしてやる必要がありそうです。

サブクラス化が面倒

紹介した2つの方法は何らかの View のサブクラスを作る必要があるため少々面倒です。そこでサブクラス化を行わない方法も少し紹介します。

REKit を使って任意のインスタンスメソッドをその場でオーバーライド

REKit という インスタンス毎の動的メソッド実装/上書き機能を備えた ライブラリを使ってみます。

以下のように、任意のインスタンスに REKit が提供する respondsToSelector:withKey:usingBlock: を使って pointInside:withEvent: をオーバーライドします。block の中の実装は先に紹介したものと同じです。

// REKit をつかって rekitButton のインスタンスに対してのみ `pointInside:withEvent:` をオーバーライド
[rekitButton respondsToSelector:@selector(pointInside:withEvent:)
                        withKey:nil
                     usingBlock:^BOOL(id receiver, CGPoint point, UIEvent *event) {
                         UIButton *button = (UIButton *)receiver;
                         CGRect rect = button.bounds;
                         rect.origin.x += -20;
                         rect.origin.y += -20;
                         rect.size.width -= (-20 + -20);
                         rect.size.height -= (-20 + -20);
                         return CGRectContainsPoint(rect, point);
                     }];

REKit は Runtime 関数等を使ってアグレッシブなことをしてくれるハイパーなやつです。場合によっては不具合もあるようですが頼もしいライブラリです。黒魔術的なものなのであまり使いすぎるのも少し怖い気がしますが今回の用途以外でも調査目的なんかにも使えそうなので覚えておくと便利だと思います。

サンプルコード

サンプルコードを一応おいておきます。