Objective-C
iOS
メモ
ズーム
タップ

[Objective-C] タップによるズーム機能の実装メモ

More than 3 years have passed since last update.

[PDF] iOS Scroll Viewプログラミングガイドを読んで、タップによるズーム機能が紹介されていたので、実際に作ってみたもののメモです。

実際のサンプルがgithubに公開されているので、そちらを見てみると分かりやすいと思います。
ここでは、ガイドラインを元に自分でコードを書いてみて気づいた点などを書いていきます。

タップによるズーム

ピンチジェスチャに関しては基本的な実装をフレームワーク側で行っていますが、タップによるズーム(ダブルタップでズーム、など)を実装するにはアプリケーション側での実装が必要です。

該当ビューのサブクラス

タップによるズームをさせたいビューをサブクラス化し、タッチに対しての制御を実装します。
また、いくつかのプロトコルも定義します。

SMPView.h
@protocol TapDetectingViewDelegate;

@interface SMPView : UIView

@property (assign, nonatmic) id<TapDetectingViewDelegate> delegate;

@end

@protocol TapDetectingViewDelegate <NSObject>

@optional
- (void)tapDetectingView:(SMPView *)view goSingleTapAtPoint:(CGPoint)tapPoint;
- (void)tapDetectingView:(SMPView *)view goDoubleTapAtPoint:(CGPoint)tapPoint;
- (void)tapDetectingView:(SMPView *)view goTwoFingerTapAtPoint:(CGPoint)tapPoint;

@end

プロトコルのメソッドは、このビューがタッチを識別し、 シングルタップダブルタップ2本指タップ を判断したあとにデリゲートに対してプロトコロで定義されたメソッドを呼び出します。

SMPView.m
@interface SMPView ()

@property (assign, nonatomic) BOOL multipleTouches;
@property (assign, nonatomic) BOOL twoFingerTapIsPossible;
@property (assign, nonatomic) CGPoint tapLocation;

- (void)handleSingleTap;
- (void)handleDoubleTap;
- (void)handleTwoFingerTap;

@end

multipleTouchestwoFingerTapIsPossibleプロパティは、それぞれマルチタッチかどうか、2本指タップかどうかを表すBOOL値です。
タッチ開始時と終了時で適切にハンドリングし、デリゲートメソッドを呼び出すのに利用します。

@implementation SMPView

- (id)initWithFrame:(CGRect)frame
{
    if (self = [super initWithFrame:frame]) {
        // マルチタッチを有効化
        self.multipleTouchEnabled = YES;

        self.twoFingerTapIsPossible = NO;
        self.multipleTouches        = NO;
    }

    return self;
}
// ----------------------------------------------
// タッチ制御
// ----------------------------------------------

// タッチ開始処理
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    // 保留中のhandleSingleTapメッセージがあればそれを取り消す
    [NSObject cancelPreviousPerformRequestsWithTarget:self
                                             selector:@selector(handleSingleTap)
                                               object:nil];

    // タッチの状態を更新
    if ([[event touchesForView:self] count] > 1) {
        self.multipleTouches = YES;
    }
    if ([[event touchesForView:self] count] > 2) {
        // 指が3本以上触れていたら2本指タップの状態を`NO`に
        self.twoFingerTapIsPossible = NO;
    }
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    // touches.countは話された指の数
    // [event touchesForView:self].countは、直前までViewに触れていた指の数
    BOOL allTouchesEnded = ([touches count] == [[event touchesForView:self] count]);

    // まず単純なシングル/ダブルタップを判定する
    // 複数回のタッチが行われていない場合のみ、シングル/ダブルタップの可能性がある
    if (!self.multipleTouches) {
        UITouch *touch = [touch anyObject];
        self.tapLocation = [touch locationInView:self];

        // シングルタップ
        if ([touch tapCount] == 1) {
            [self performSelector:@selector(handleSingleTap)
                      withObject:nil
                      afterDelay:0.35];
        }
        else if ([touch tapCount] == 2) {
            [self handleDoubleTap];
        }
    }

    // 複数回のタッチが行われていた場合は、それが2本指のタップだったかどうか、
    // また、そうである状況が否定されていないか確認する
    else if (self.multipleTouches && self.twoFingerTapIsPossible) {

        // ケース1: 両方のタッチの同時の終了の場合
        if ([touches count] == 2 && allTouchesEnded) {
            int i = 0;
            NSUinteger tapCounts[2];
            CGPoint tapLocation[2];

            for (UITouch *touch in touches) {
                tapCount[i]    = [touch tapCount];
                tapLocation[i] = [touch locationInView:self];
                i++;
            }

            if (tapCounts[0] == 1 && tapCounts[1] == 1) {
                // 両方ともシングルタップであれば、これは2本指のタップ
                self.tapLocation = midpointBetweenPoints(tapLocations[0], tapLocations[1]);
                [self handleTwoFingerTap];
            }
        }

        // ケース2: これが1回のタッチの終了で、もう1つはまだ終了していない場合
        else if ([touches count] == 1 && !allTouchesEnded) {
            UITouch *touch = [touches anyObject];

            if ([touch tapCount] == 1) {
                // タッチが1回のタップであれば、その位置を保存し、
                // 2番目のタッチの位置と平均値を求められるようにする
                self.tapLocation = [touch locationWithView:self];
            }
            else {
                self.twoFingerTapIsPossible = NO;
            }
        }

        // ケース3: これが2つのタッチの2番目の終了である場合
        else if ([touches count] == 1 && allTouchesEnded) {
            UITouch *touch = [touches anyObject];

            if ([touch tapCount] == 1) {
                // 最後のタッチがシングルタップであれば、これは2本指のタップ
                self.tapLocation = midpointBetweenPoints(self.tapLocation, [touch locationInView:self]);
                [self handleTwoFingerTap];
            }
        }
    }

    // すべてのタッチが終了した場合、タッチの監視状態をリセットする
    if (allTouchesEnded) {
        self.twoFingerTapIsPossible = YES;
        self.multipleTouches = NO;
    }
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    self.twoFingerTapIsPossible = YES;
    self.multipleTouches = NO;
}
// ----------------------------------------------
// タップイベント制御
// ----------------------------------------------

- (void)handleSingleTap
{
    if ([self.delegate respondsToSelector(@selector(tapDetectingView:goSingleTapAtPoint:)]) {
        [self.delegate tapDetectingView:self goSingleTapAtPoint:self.tapLocation];
    }
}

- (void)handleDoubleTap
{
    if ([self.delegate respondsToSelector(@selector(tapDetectingView:goDoubleTapAtLocation:)]) {
        [self.delegate tapDetectingView:self goDoubleTapAtLocation:self.tapLocation];
    }
}

- (void)handleTwoFingerTap
{
    if ([self.delegate respondsToSelector(@selector(tapDetectingView:goTwoFingerTapAtLocation:)]) {
        [self.delegate tapDetectingView:self goTwoFingerTapAtPoint:self.tapLocation];
    }
}

@end
// ----------------------------------------------
// ヘルパー関数
// ----------------------------------------------

CGPoint midpointBetweenPoints(CGPoint a, CGPoint b)
{
    CGFloat x = (a.x + b.x) / 2.0;
    CGFloat y = (a.y + b.y) / 2.0;
    return CGPointMake(x, y);
}

コントローラクラスでデリゲートを実装する

コントローラ側では、ScrollViewとズーム対象のビューを入れ子にして生成します。
その上で、ScrollViewのdelegateプロパティに、自分自身をセットします。(つまり普通のdelegateの使い方です)

// SMPViewController.m

// デリゲートのみ記載

#define ZOOM_STEP 1.5

#pragma mark delegate methods

- (void)tapDetectingView:(SMPView *)view goSingleTapAtPoint:(CGPoint)tapPoint
{
    // シングルタップは処理しない
}

// ダブルタップ時の処理
- (void)tapDetectingView:(SMPView *)view goDoubleTapAtPoint:(CGPoint)tapPoint
{
    float newScale = self.scrollView.zoomScale / ZOOM_STEP;
    CGRect zoomRect = [self zoomRectForScale:newScale withCenter:tapPoint];
    [self.scrollView zoomToRect:zoomRect animated:YES];
}

// 2本指タップ時の処理
- (void)tapDetectingView:(SMPView *)view goTwoFingerTapAtPoint:(CGPoint)tapPoint
{
    float newScale = self.scrollView.zoomScale / ZOOM_STEP;
    CGRect zoomRect = [self zoomRectForScale:newScale withCenter:tapPoint];
    [self.scrollView zoomToRect:zoomRect animated:YES];
}

#pragma mark helper function

- (CGRect)zoomRectForScale:(float)scale withCenter:(CGPoint)center
{
    CGRect zoomRect;

    // scaleで割ることで、ズーム対象となる矩形のサイズを決める
    zoomRect.size.height = self.scrollView.frame.size.height / scale;
    zoomRect.size.width  = self.scrollView.frame.size.width  / scale;

    // 矩形の中心をズームの中心にする
    zoomRect.origin.x = center.x - (zoomRect.size.width  / 2.0);
    zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0);

    return zoomRect;
}

解説

基本的にどのデリゲートメソッドも、渡された位置を元に処理を行っています。
処理の中心になるのがzoomRectForScale:withCenter:メソッドです。

これは、渡されたスケールと位置から、ズーム対象となる矩形を生成して返します。
zoomRectのイメージは、生成された矩形が元のスケールに戻るように計算される、と(自分は)イメージしています。
(つまり、小さい矩形なら、「それを元に戻す=拡大」、大きい矩形なら、「それを元に戻す=縮小」、というイメージ)

あとは生成された矩形を、ScrollViewのzoomToRect:animated:メソッドに渡してやることでズームが実行されます。