Posted at

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

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