[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
multipleTouches、twoFingerTapIsPossibleプロパティは、それぞれマルチタッチかどうか、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:メソッドに渡してやることでズームが実行されます。