2012年5月11日金曜日

マルチタッチイベントサンプル2 - カスタムGesture Recognizer

前回の「マルチタッチイベントサンプル1 - Gesture Recognizer」では、UIGestureRecognizerを使った基本的なジェスチャー認識を紹介しました。
今回は、カスタムGesture Recognizerの作成として、UIGestureRecognizerのサブクラス「UIGestureRecognizerSubclass」のサンプル実装です。ドキュメントは「「iOSイベント処理ガイド」の45ページ目」になります。

カスタムGesture Recognizer【iOS 3.2以降】

用意されているUIGestureRecognizerクラスで十分なことが多いですが、それでもやはり欲しい機能を満たしてくれない場合もあります。そのため、UIGestureRecognizerのサブクラスであるUIGestureRecognizerSubclassを利用してジェスチャー認識を拡張することができます。
手順は、以下の通りです。
  1. UIGestureRecognizerSubclass.hをインポートし、UIGestureRecognizerを継承する
    #import <UIKit/UIGestureRecognizerSubclass.h>
    
  2. 以下のメソッドをオーバーライドする
    - (void)reset;
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
    - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
    - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
    - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
    
  3. 上記のreset以外(touchesBegan,touchesMoved,touchesEnded,touchesCancelled)のメソッドで、現在の状態を表す「self.state」に以下の値を代入する
    typedef enum {
        UIGestureRecognizerStatePossible,
        UIGestureRecognizerStateBegan,
        UIGestureRecognizerStateChanged,
        UIGestureRecognizerStateEnded,
        UIGestureRecognizerStateCancelled,
        UIGestureRecognizerStateFailed,
        UIGestureRecognizerStateRecognized = UIGestureRecognizerStateEnded
    } UIGestureRecognizerState;
    
「1.」「2.」はそのままなのですが、「3.」は、iOS 4とiOS 5とで挙動が異なっているので注意が必要でした。後述します。

単発のジェスチャーと連続的なジェスチャー

まず、どのような状態があるのかですが、実装するジェスチャーが「単発」なのか「連続的」なのかで異なります。
  • 単発のジェスチャー
    • 一つの状態変化だけで表されるものです。例えば、タップやスワイプは一度タッチ動作が発生してすぐに終わりなので、単発のジェスチャーです。
  • 連続的なジェスチャー
    • 状態変化が連続的に発生するものです。例えば、ピンチイン・ピンチアウトなどで縮小・拡大が必要な場合、大きさが変わってゆくので連続的なジェスチャーとなります。
動かすから連続的なジェスチャーであるというわけではなく、「ジェスチャーが成功したかどうかが必要」な場合には単発のジェスチャー、「ジェスチャーから連続的に発生する情報が必要」な場合には連続的なジェスチャーとして実装することになります。

状態遷移図

次に、状態遷移ですが、それぞれ以下の通りです。長いのでUIGestureRecognizerStateの部分を省略しています。赤い線はiOS 4とiOS 5とでの違いですが、説明はこの後にします。
しかし実際には、連続的なジェスチャーであっても「UIGestureRecognizerStatePossible -> UIGestureRecognizerStateRecognized」という遷移を辿ることもあるので、実装する際には以下の遷移図で考える必要があります。

ハンドラメソッドの呼び出され方

そして、各メソッドの処理順序です。単発のジェスチャーの場合は「UIGestureRecognizerStateRecognizedかUIGestureRecognizerStateFailed」になると一度だけハンドラが実行されて終わります。
しかし、連続的なジェスチャーの場合は少し複雑で、「UIGestureRecognizerStateBeganかUIGestureRecognizerStateChanged」に変化すると、それ以降は常にハンドラが呼ばれるようになります。「state」に値を代入したらその時だけハンドラが処理されるのかと思っていたのですが、一度でも「UIGestureRecognizerStateBeganかUIGestureRecognizerStateChanged」の状態にすると、「UIGestureRecognizerStateEndedかUIGestureRecognizerStateCancelled」を代入するまでハンドラが処理され続けます。
なお、単発のジェスチャーでも連続的なジェスチャーでも「UIGestureRecognizerStatePossible」の状態ではハンドラは呼ばれません。
複雑なので、何かあったら見る程度で良いと思います。

連続的なジェスチャーが上の図だけでは分かりづらいので、iOS 4とiOS 5との違いも含めて説明します。基本的な流れは説明した通り、以下の順にメソッドが呼ばれます。
  • タッチされたらtouchesBegan
  • 動かされたら、動かされる度にtouchesMoved
  • 離されたらtouchesEnded
iOS 4の場合は、「self.state = UIGestureRecognizerStateBegan」とすると、別な値を代入するまで「state」に変化はありません。
しかしiOS 5の場合は、「self.state = UIGestureRecognizerStateBegan」とすると、次のメソッドが呼ばれる一度だけ「UIGestureRecognizerStateBegan」で、それ以降の呼び出しは自動的に「UIGestureRecognizerStateChanged」になってしまいます。
確かにドキュメントを見ても、
単発のジェスチャ用のGesture Recognizerは、PossibleからRecognized(UIGestureRecognizerStateRecognized)に遷移します。一方、連続的なジェスチャ用のGesture Recognizerは、最初にジェスチャを認識したときは、PossibleからBegan (UIGestureRecognizerStateBegan)に遷移します。次に、BeganからChanged (UIGestureRecognizerStateChanged)に遷移します。その後は、ジェスチャに変化があるたびにChangedからChangedに遷移します。
と「UIGestureRecognizerStateBeganからUIGestureRecognizerStateChanged」に変わる条件が書かれていません。サブクラスを実装するときやハンドラの中で「state」で条件分岐する際には注意が必要です。

サンプル

ソースコードはGitHubに公開してあります。
iOS-SampleCodes/GestureRecognizer-Customized - GitHub

実用性もありそうな「マウスジェスチャー機能」をサンプルとしました。
価格: 無料 (記事公開時)
カテゴリ: ユーティリティ
App Storeで詳細を見る。
Sleipnir Mobile for iPhone / iPadでも実装されており、便利です。今回は「StrokeGestureRecognizer」クラスとして、Viewはジェスチャーの軌跡を描くだけのものです。

マウスジェスチャー機能をカスタムGestureRecognizerとして実装する方法として色々と考えましたが、認識させたい形を複数のジェスチャーとして登録し、それぞれのジェスチャーが認識される(あるいは違うと判定される)と、ハンドラが実行されるようにしてあります。
/* "I型" のジェスチャー */
StrokeGestureRecognizer *iShapedGesture = [[StrokeGestureRecognizer alloc] initWithTarget:self action:@selector(handleIShapedGesture:)];

// 複数のジェスチャーを認識させるためのデリゲート
iShapedGesture.delegate = self;

// ジェスチャーとして認識させるために必要な線の長さ
[iShapedGesture setLowerLimitOfStrokeLength:45];

// 下
[iShapedGesture addWantedStroke:StrokeGestureRecognizerDirectionDown];

// Viewに登録
[self.view addGestureRecognizer:iShapedGesture];
[iShapedGesture release];


/* "L型" のジェスチャー */
StrokeGestureRecognizer *lShapedGesture = [[StrokeGestureRecognizer alloc] initWithTarget:self action:@selector(handleLShapedGesture:)];
lShapedGesture.delegate = self;
[lShapedGesture setLowerLimitOfStrokeLength:45];

// 下・右の順
[lShapedGesture addWantedStroke:StrokeGestureRecognizerDirectionDown];
[lShapedGesture addWantedStroke:StrokeGestureRecognizerDirectionRight];

// Viewに登録
[self.view addGestureRecognizer:lShapedGesture];
[lShapedGesture release];

setLowerLimitOfStrokeLengthメソッドでジェスチャーとして認識させるために必要な線の長さを決め、addWantedStrokeに必要なジェスチャーの順番を登録してゆきます。その後、ジェスチャーが認識されると登録したハンドラが呼ばれます。

UIGestureRecognizerは、デフォルトでは認識させたいジェスチャーを複数登録しても、複数のジェスチャーを認識してくれません。そのため、
UIGestureRecognizerDelegateのgestureRecognizerをオーバーライドすることにより、複数のジェスチャーを同時に認識させています。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
    return YES;
}

また、GestureRecognizerはデフォルトのままだと「Recognized/Failed/Ended/Cancelled」の状態になるとタッチイベントをビューに配信するのを止めてしまいます。そのため、
self.cancelsTouchesInView = NO;
として、タッチイベントの配信をキャンセルしないようにしています。

スクリーンショット

登録されていないものは認識できません

I字(下)と、L字(下→右)の認識結果

指を離さなくてもジェスチャーを認識し、離すと決定します

U字、O字、C字の例

いずれも、方向があっていれば自然な流れで認識できます

次回

次は、モーションイベントの検知で「モーションイベントサンプル1 - シェイクと回転検知」です。

2 件のコメント:

  1. 恐ろしく分かりやすい記事ありがとうございます。
    ジェスチャーの状態遷移を探しておりましたところ、まさに求めていたものがここにありました。
    思わず、コメントさせて頂きました。

    返信削除
    返信
    1. 嬉しいコメントありがとうございます。iOS5の時代から止まって閉まっているため、最新のSDKでどうか試していないため、今後試すことがありましたらまた更新したいと思っております。

      削除