iOS GoogleMapsの新しい拡大縮小ジェスチャをGestureRecognizer化する

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Google Mapsアプリの拡大・縮小ジェスチャ

先日リリースされたiOSの新しいGoogle Mapsに、マップの拡大・縮小操作のジェスチャが新しく追加されました。ダブルタップ後にそのまま指を離さずに上下にパン(ドラッグ)すると拡大・縮小操作が行えるというもので、実際に使ってみると片手でも操作しやすくとてもいい感じでした。そこで、今回はこのジェスチャを簡単に使えるGestureRecognizerとして実装してみました。

開発環境は以下の通りです。

  • OSX Mountain Lion
  • Xcode 4.5.2
  • iOS SDK 6.0
  • Apple LLVM Compiler 4.1
  • iPhone5 (iOS 6.0.1)

ソースコードはGitHubで公開しています。

ジェスチャのルールを考える

実装に入る前に、まずはジェスチャのルールについて考えます。

まずは、ジェスチャの開始と終了についてです。今回GestureRecogizerで検出したいジェスチャのフローは以下のようになります。

  1. ダブルタップの2回目のタッチダウン時にジェスチャ成立
  2. ジェスチャ成立後は、ジェスチャを成立させた指(タッチ)のY軸上の動きを追跡
  3. ジェスチャを成立させたタッチがタッチアップしたらジェスチャ終了

上記のフローに加え、以下のルールを追加します。

  • スクリーン上のタッチ数が1つである場合のみジェスチャが成立
  • ジェスチャ成立後に別のタッチが検出された場合、ジェスチャを終了させる
  • ジェスチャ成立後、上方向(Y軸負の方向)にドラッグすると拡大し、下方向(Y軸正の方向)にドラッグすると縮小する
  • Y軸方向のドラッグ1pxにつき、1%の拡大・縮小をする

ルールはこのくらいで十分そうですね。では、実際に実装してみます。

UIGestureRecognizer

GestureRecognizerを作成する際には、UIGestureRecognizerクラスを継承します。UIGestureRecognizerクラスは、タッチのトラックやビューへのアタッチなどのジェスチャの検出とその通知に必要な処理が実装されており、抽象クラスのような役割となっています。

サブクラスの実装ファイルでは、UIGestureRecoginizerクラスの継承時にのみ利用するために用意されているメンバを参照する必要があります。これらのメンバの宣言はUIGestureRecognizerSubclass.hというヘッダファイルにまとまっていますので、以下のヘッダファイルの参照を実装ファイルに記述しておきます。

#import <UIKit/UIGestureRecognizerSubclass.h>

UIGestureRecognizerのステート

GestureRecognizerは大きく以下の2つの種類に分けることができます。

  • ジェスチャの成立のみで構成される、単発のジェスチャ(タップ・スワイプ等)
  • ジェスチャの成立とその後の変化から構成される、連続的なジェスチャ(ピンチ・ローテーション等)

UIGestureRecognizerは、ジェスチャが今どのフェーズにあるかという状態を表すUIGestureRecognizerState型のstateプロパティを持っています。このstateプロパティをサブクラスで操作することによって、ジェスチャの成立などのステートの管理を行うとともに、アタッチしたビューへの通知など、ステートの変化に伴う処理が行われるようになっています。先ほどの単発のジェスチャと連続的なジェスチャではステートのサイクルが異なります。

今回作成するジェスチャは連続的なジェスチャです。連続的なジェスチャのステートのサイクルを大まかに説明すると、ジェスチャ成立時にUIGestureRecognizerStateBegan、ジェスチャ終了時にUIGestureRecognizerStateEnded、成立から終了までの間はUIGestureRecognizerStateChangedとなります。ステートのサイクルについては、こちらのサイトの解説が詳しいので参考にして下さい。

UIGestureRecognizerのサブクラスの実装

サブクラスでオーバーライドするメソッドは以下の通りです。

  • -reset
  • -touchesBegan:withEvent:
  • -touchesMoved:withEvent:
  • -touchesEnded:withEvent:
  • -touchesCancelled:withEvent:

reset以外は普段使っているタッチイベントのハンドラメソッドと同じです。このハンドラメソッドでタッチに関する情報を取得して、ジェスチャの状態を判定します。resetは、stateにUIGestureRecognizerStateEndedなどが設定されることによって、成立したジェスチャが終了した際に呼ばれるメソッドです。メソッド名から分かる通り、GestureRecognizerの内部をリセットする処理を記述します。このメソッドを直接呼び出す必要はありません。

タッチ開始時の処理

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    // 2つ以上のタッチがある場合とダブルタップ以外は処理しない
    if (event.allTouches.count != 1 || touch.tapCount != 2) {
        if (trackingTouch) {
            self.state = UIGestureRecognizerStateEnded;
        }
        return;
    }
    
    trackingTouch = touch;
    self.state = UIGestureRecognizerStateBegan;
}

シングルタッチでタップ回数が2回の場合に、ジェスチャが開始されたとみなし、stateをUIGestureRecognizerStateBeganに変更します。後で処理対象のタッチを見分けるために、ジェスチャの開始を成立させたタッチを表すUITouchインスタンスの参照を保持しておきます。

また、UIEventのallTouchesで全てのタッチを取得して、スクリーン上で2つ以上のタッチが行われているかをチェックしています。

タッチ中の処理

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    // ジェスチャが開始されていない場合とジェスチャの開始を成立させたタッチが含まれない場合は処理をしない
    if (!trackingTouch || ![touches containsObject:trackingTouch]) {
        return;
    }
    
    CGPoint point = [trackingTouch locationInView:nil];
    CGPoint previousPoint = [trackingTouch previousLocationInView:nil];
    CGFloat delta = previousPoint.y - point.y;
    _scale = 1.0f + delta * ScalePerPixel;
}

ジェスチャの開始を成立させたタッチが含まれない場合に、前回タッチされたポイントとの間のY軸上の距離を元にスケールの倍率を変更します。上方向にドラッグされた場合にはスケールの倍率が高くなるよう、下方向にドラッグされた場合にはスケールの倍率が低くなるよう処理しています。ScalePerPixelは0.01fのCGFloat定数値で、ドラッグによる移動量の1%が倍率に反映されるようにしています。

なお、本来はジェスチャ開始時のタッチのポイントからの距離を倍率に反映するようにしないと、Y軸上を往復する間に倍率の値に若干の誤差が生じます。しかし、誤差は無視できる範囲でユーザーは気づかない上に、こちらの方が実装も実装したGestureRecognizerの利用も簡単になります。

このGestureRecognizerを利用する側からスケールの倍率の値にアクセスできるよう、ヘッダファイルでscaleという名前のプロパティを定義していますので、このプロパティに算出したスケールの倍率の値をセットします。

@property (nonatomic, readonly) CGFloat scale;

なお、stateをUIGestureRecognizerStateBeganにセットすると、自動的に次のタッチイベント検出までにstateがUIGestureRecognizerStateChangedに変更されます。したがって、ここではstateの変更は必要ありません。

タッチ終了時の処理

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    // ジェスチャが開始されていない場合とジェスチャの開始を成立させたタッチが含まれない場合は処理をしない
    if (!trackingTouch || ![touches containsObject:trackingTouch]) {
        return;
    }
    
    self.state = UIGestureRecognizerStateEnded;
}

ジェスチャの開始を成立させたタッチが終了した場合、ジェスチャも終了させます。stateにUIGestureRecognizerStateEndedをセットします。

タッチキャンセル時の処理

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    // ジェスチャが開始されていない場合とジェスチャの開始を成立させたタッチが含まれない場合は処理をしない
    if (!trackingTouch || ![touches containsObject:trackingTouch]) {
        return;
    }
    
    self.state = UIGestureRecognizerStateCancelled;
}

ジェスチャの開始を成立させたタッチがキャンセルされた場合、ジェスチャもキャンセルします。stateにUIGestureRecognizerStateCancelledをセットします。

リセット処理

- (void)reset
{
    trackingTouch = nil;
    _scale = 1.0f;
}

ジェスチャの処理で利用したフィールドをリセットしています。

これでGoogle Mapsのような拡大縮小ジェスチャを検出するGestureRecognizerができました。簡単ですね。

ジェスチャを組み込んだ簡易イメージビューア

サンプルアプリの実装

では、作成したGestureRecognizerを簡単なイメージビューアのサンプルで試してみます。以下は、ViewControllerの実装です。

@interface CMViewController : UIViewController

@property (weak, nonatomic) IBOutlet UIScrollView *scrollView; @property (weak, nonatomic) IBOutlet UIImageView *imageView;

@end

@implementation CMViewController

#pragma mark - Lifecycle methods

- (void)viewDidLoad { [super viewDidLoad];

// UIImageViewにイメージをセット UIImage *image = [UIImage imageNamed:@"image.jpg"]; self.imageView.image = image; // frameのサイズをセットしたイメージの解像度に合わせる CGPoint imageViewOrigin = self.imageView.frame.origin; self.imageView.frame = CGRectMake(imageViewOrigin.x, imageViewOrigin.y, image.size.width, image.size.height);

// 最小スケールは縦も横もUIScrollViewのboundsに収まる大きさ CGFloat minWidthScale = self.scrollView.bounds.size.width / self.imageView.frame.size.width; CGFloat minHeightScale = self.scrollView.bounds.size.height / self.imageView.frame.size.height; self.scrollView.minimumZoomScale = MIN(minWidthScale, minHeightScale); self.scrollView.maximumZoomScale = 3.0f; // 初期状態は最小スケールにして画像が全て見えるようにする self.scrollView.zoomScale = self.scrollView.minimumZoomScale;

// Google Maps風のジェスチャの追加 CMDoubleTapAndPanGestureRecognizer *gesture = [[CMDoubleTapAndPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTapAndPanGesture:)]; [self.scrollView addGestureRecognizer:gesture]; }

- (void)didReceiveMemoryWarning { [super didReceiveMemoryWarning]; // Dispose of any resources that can be recreated. }

#pragma mark - UIScrollViewDelegate methods

- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView { return self.imageView; }

- (void)scrollViewDidZoom:(UIScrollView *)scrollView { // イメージの表示サイズがUIScrollViewのboundsより小さい場合、センタリングするようにする

CGPoint imageViewOrigin = self.imageView.frame.origin; CGFloat imageViewOriginX = imageViewOrigin.x; CGFloat imageViewOriginY = imageViewOrigin.y;

CGSize imageViewSize = self.imageView.frame.size;

if (imageViewSize.width < self.scrollView.bounds.size.width) { imageViewOriginX = CGRectGetMidX(self.scrollView.frame) - imageViewSize.width * 0.5f; } else { imageViewOriginX = 0.0f; } if (imageViewSize.height < self.scrollView.bounds.size.height) { imageViewOriginY = CGRectGetMidY(self.scrollView.frame) - imageViewSize.height * 0.5f; } else { imageViewOriginY = 0.0f; } self.imageView.frame = CGRectMake(imageViewOriginX, imageViewOriginY, imageViewSize.width, imageViewSize.height); } #pragma mark - Handlers - (void)handleDoubleTapAndPanGesture:(CMDoubleTapAndPanGestureRecognizer *)gesture { if (gesture.state == UIGestureRecognizerStateChanged) { // 現在のスケールにジェスチャで検出された倍率変化を乗算 self.scrollView.zoomScale = self.scrollView.zoomScale * gesture.scale; } } @end [/c]

UIはUIViewのサブビューにUIScrollViewを、そのサブビューにUIImageViewを追加しただけです。

サンプルアプリの実行

サンプルアプリを実行した際の様子です。分かりやすくするために、タッチ操作の場所にマウスカーソルを置いています。

gesture01

ダブルタップの2回目のタップを離さずに上にドラッグするとイメージが拡大されていきます。

gesture02

まとめ

GestureRecognizerの実装は簡単でしたが、どこでも使い回しができるコンポーネントができました。これで、クライアントからGoogle Maps風の拡大縮小ジェスチャを組み込みたいという要望があってもすぐに対応できますね。さっと作ったものなのであまり検証はしていませんが、よろしければどうぞお使い下さい。

参考サイト

iOSイベント処理ガイド
マルチタッチイベントサンプル2 - カスタムGesture Recognizer