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



[[PDF] iOS Scroll Viewプログラミングガイド](https://developer.apple.com/jp/devcenter/ios/library/documentation/UIScrollView_pg.pdf)を読んで、タップによるズーム機能が紹介されていたので、実際に作ってみたもののメモです。

[実際のサンプル](https://github.com/acekiller/iOS-Samples/tree/master/ScrollViewSuite/1_TapToZoom)がgithubに公開されているので、そちらを見てみると分かりやすいと思います。
ここでは、ガイドラインを元に自分でコードを書いてみて気づいた点などを書いていきます。


##タップによるズーム

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

####該当ビューのサブクラス

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

#####SMPView.h

```objc
@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

```objc
@interface SMPView ()

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

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

@end
```

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

```objc
@implementation SMPView

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

	return self;
}
```

```objc
// ----------------------------------------------
// タッチ制御
// ----------------------------------------------

// タッチ開始処理
- (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;
}
```

```objc
// ----------------------------------------------
// タップイベント制御
// ----------------------------------------------

- (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
```

```objc
// ----------------------------------------------
// ヘルパー関数
// ----------------------------------------------

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の使い方です）

```objc
// 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:`メソッドに渡してやることでズームが実行されます。