ジェスチャーシリーズの最後となるのは二本指を使った回転の実装を行う。

前回までの記事
[Android] 様々なジェスチャーを処理する(1) 拡大縮小
[Android] 様々なジェスチャーを処理する(2) 移動

二本指での回転は、一見するととても難易度が高いように思える。
しかし、ある数学の考え方を使えば驚くほど簡単に実現できる。

今回の実装を行うにあたり、ここを参考にさせていただいた。

Android Two finger rotation - Stack Overflow

回転を検知する考え方
まずは、どうやって回転を検知するのか考えてみよう。
二本指を回転させた場合このような動きになる。
gesture_ss6
A1とB1は回転を開始するときの指の位置、A2とB2は回転のために動かした指の位置となる。
この時の角度αを求めることが出来れば後はMatrixで処理するだけで出来そうだ。

ではこの角度αはどうやって求めれば良いのか。
A1B1とA2B2を三角形として考えてみよう。
gesture_ss7
このように考え、β1とβ2の値がわかればその差であるαを求めることが出来る。

ではどうすればβ1とβ2を求めることが出来るのか。
その答えはアークタンジェントだ。
JavaではMathクラスにatan2()というメソッドがあり、これを使えば魔法のようにβ1とβ2を求めることが出来る。

詳細な理屈はここでは説明しないが、以下の様な三角形があったとき
Math.atan2(y, x)
とすればθが返される。
gesture_ss8
それでは早速実装を行ってみよう。

回転ジェスチャーの実装
実装の流れは前回行った移動と同じだ。
まずは雛形を作成する。

回転のジェスチャーを受け取るためのリスナーはこうなる。
public class RotationGestureListener {
    public void onRotation(RotationGestureDetector detector)
    {
    }
    
    public void onRotationBegin(RotationGestureDetector detector)
    {
    }
    
    public void onRotationEnd(RotationGestureDetector detector)
    {
    }
}
移動の場合と形式は同じなので説明は不要だろう。

次はジェスチャー本体の雛形だ。
public class RotationGestureDetector {
    private RotationGestureListener mListener;

    private float mX1, mY1, mX2, mY2;
    private int mPointerID1, mPointerID2;
    private float mFocusX, mFocusY;
    private float mAngle;

    public RotationGestureDetector(RotationGestureListener listener)
    {
        mListener = listener;
    }

    public boolean onTouchEvent(MotionEvent event) {
        int eventAction = event.getActionMasked();
        int pointerIndex = event.getActionIndex();
        int pointerId = event.getPointerId(pointerIndex);
        float x = event.getX(pointerIndex);
        float y = event.getY(pointerIndex);
        
        switch (eventAction) {
        case MotionEvent.ACTION_DOWN:
            // 最初の指の設定
            mPointerID1 = pointerId;
            mPointerID2 = -1;
            mX1 = x;
            mY1 = y;
            break;
            
        case MotionEvent.ACTION_POINTER_DOWN:
            // 3本目の指以降は無視する
            if (mPointerID1 == -1)
            {
                mPointerID1 = pointerId;
                mX1 = x;
                mY1 = y;
                if (mPointerID2 != -1)
                {
                    int ptrIndex = event.findPointerIndex(mPointerID2);
                    mX2 = event.getX(ptrIndex);
                    mY2 = event.getY(ptrIndex);
                    mListener.onRotationBegin(this);
                }
            }
            else if (mPointerID2 == -1)
            {
                mPointerID2 = pointerId;
                mX2 = x;
                mY2 = y;
                if (mPointerID1 != -1)
                {
                    int ptrIndex = event.findPointerIndex(mPointerID1);
                    mX1 = event.getX(ptrIndex);
                    mY1 = event.getY(ptrIndex);
                    mListener.onRotationBegin(this);
                }
            }
            break;
            
        case MotionEvent.ACTION_MOVE:
            break;
            
        case MotionEvent.ACTION_UP:
            mPointerID1 = -1;
            mPointerID2 = -1;
            break;
            
        case MotionEvent.ACTION_POINTER_UP:
            if (mPointerID1 == pointerId)
            {
                mPointerID1 = -1;
                if (mPointerID2 != -1)
                {
                    mListener.onRotationEnd(this);
                }
            }
            else if (mPointerID2 == pointerId)
            {
                mPointerID2 = -1;
                if (mPointerID1 != -1)
                {
                    mListener.onRotationEnd(this);
                }
            }
            break;
        }
        
        return true;
    }
}
こちらもおおまかなところは移動の場合とほぼ同じだ。

まずはACTION_POINTER_DOWNを見てみよう。
ここは当然二本目以降の指がタッチした場合に呼ばれる。
最初の指と二本目の指との場合で、ポインタIDと座標を保持しておき、その上でもう一つのポインタIDがある(つまりもう一つの指がタッチしている)場合に回転開始とする。

次はACTION_POINTER_UPだ。
タッチしている二本の指のうち、どちらかが離されたら回転は終了ということにする。
対となるポインタIDを-1かどうかチェックしているのは、mListener.onRotationEnd()が二回呼ばれないようにするためだ。

次はACTION_MOVEを実装してみよう。
以下のコードをACTION_MOVEに追加する。
// 1
float x1 = event.getX(event.findPointerIndex(mPointerID1));
float y1 = event.getY(event.findPointerIndex(mPointerID1));
float x2 = event.getX(event.findPointerIndex(mPointerID2));
float y2 = event.getY(event.findPointerIndex(mPointerID2));

// 2
float angle1 = calcAngle(mX1, mY1, mX2, mY2);
float angle2 = calcAngle(x1, y1, x2, y2);

// 3
float deltaAngle = angle2 - angle1;
if (deltaAngle < -180.0f)
{
    deltaAngle += 360.0f;
}
else if (deltaAngle > 180.0f)
{
    deltaAngle -= 360.0f;
}
mAngle = deltaAngle;

mX2 = x2;
mY2 = y2;
mX1 = x1;
mY1 = y1;

// 4
mFocusX = calcCenter(x1, x2);
mFocusY = calcCenter(y1, y2);

// 5
mListener.onRotation(this);
ここでは回転処理の全てを行っている。
順に見ていこう。

1.それぞれの指に対応する座標を取得する。
最初に示した図のA2とB2だ。

2.タッチしている二本の指の座標から角度を計算する。
angle1は2番めに示した図のβ2、angle2はβ1だ。
calcAngle()は後ほど実装する。

3.二つの角度の差を求めることで今回私達が欲しい角度が手に入る。
ただし、角度は-180~180に収まるように補正しておく。

4.今回使用した座標は次にACTION_MOVEが呼び出された際に必要なので保持しておく。

5.mFocusXとmFocusYはタッチしている指の中心の座標だ。
図形を回転させるときの当たり判定等で使うことが想定される。
calcCenter()は後ほど実装する。

6.最後にリスナーを呼び出して完了だ。

次は未実装だった2つのメソッドを実装しよう。
まずはcalcAngle()だ。
private float calcAngle(float x1, float y1, float x2, float y2)
{
    return (float)Math.toDegrees(Math.atan2((y2 - y1), (x2 - x1)));
}
これが角度を導き出す魔法だ。
先ほど説明したように、Math.atan2()を使用しているだけだ。
ただし、このメソッドで返される値はラジアンなので、角度に変換するようにしてある。

次はcalcCenter()だ。
private float calcCenter(float p1, float p2)
{
    return (p1 + p2) / 2;
}
これは単純に2つの値の中間値を計算しているだけだ。

さてこれでロジックは出揃った。
後は呼び出し側で使用するための値を返すメソッドを作成しよう。
    public float getX1()
    {
        return mX1;
    }
    
    public float getY1()
    {
        return mY1;
    }
    
    public float getX2()
    {
        return mX2;
    }
    
    public float getY2()
    {
        return mY2;
    }
    
    public float getFocusX()
    {
        return mFocusX;
    }
    
    public float getFocusY()
    {
        return mFocusY;
    }
    
    public float getDeltaAngle()
    {
        return mAngle;
    }
これでRotationGestureDetectorは完成だ。
動作を確認するために、次はMainActivityに必要な処理を追加しよう。
とはいえ、ここからの処理は移動の場合とほぼ同じなので、難しいことは無い。

画像を回転する処理の実装
まずMySurfaceViewに以下のインスタンス変数を追加する。
private float mAngle;
private RotationGestureDetector mRotationGestureDetector;
mAngleは回転角度を保持するためのもので、mRotationGestureDetectorは先ほど作成したジェスチャークラスのインスタンスだ。

次はMySurfaceViewのコンストラクタのTranslationGestureDetectorの初期化の下に以下のコードを追加しよう。
mRotationGestureDetector = new RotationGestureDetector(mRotationListener);
これで準備は完了だ。
次は上記の引数で渡してある回転のリスナーを作成する。
以下のコードをMySurfaceViewに追加しよう。
private RotationGestureListener mRotationListener
= new RotationGestureListener() {
    @Override
    public void onRotation(RotationGestureDetector detector) {
        mAngle += detector.getDeltaAngle();
    }

    @Override
    public void onRotationBegin(RotationGestureDetector detector) {
    }

    @Override
    public void onRotationEnd(RotationGestureDetector detector) {
    }
};
onRotation()が呼ばれる度に発生した角度の差分をmAngleに足していく。
これをMatrixで処理してやれば回転が行われるはずだ。

present()メソッドのMatrixの処理の箇所に回転処理を追加しよう。
mMatrix.reset();
mMatrix.postScale(mScale, mScale);
mMatrix.postTranslate(-mBitmap.getWidth() / 2 * mScale, -mBitmap.getHeight() / 2 * mScale);
mMatrix.postRotate(mAngle);
mMatrix.postTranslate(mTranslateX, mTranslateY);
移動の時にも言及したが、このメソッドを呼び出す順番はとても重要だ。
これが正しくないとおかしな挙動になるので注意して欲しい。

最後にonTouch()でジェスチャーの処理を行うようにしてやれば完成だ。
public boolean onTouch(View v, MotionEvent event) {
    mRotationGestureDetector.onTouchEvent(event);
    mTranslationGestureDetector.onTouch(v, event);
    mScaleGestureDetector.onTouchEvent(event);

    present();
    return true;
}
さあ、実行して操作してみよう。
拡大縮小、移動、回転が自由自在に行えるようになった!
gesture_ss9
これで基本的なジェスチャーはほぼカバーできたはずだ。
これらとAndroid標準のGestureDetectorを組み合わせれば、おおよそのことには対応できるだろう。

今回完成したサンプルはGithubに上げてある。
chicketen / AndroidSample