前回はイメージを拡大縮小・移動・回転できるImageView拡張の下ごしらえを書きました。今回はその続きです。
まずは拡大縮小から。
ズーム状態時にタッチ位置を移動させると、まずrestoreMatrixにより、最後に保存した状態(setStateを呼んだ時点でのMatrixの状態)までMatrixを巻き戻します。Matrixは累積的に拡大縮小・移動・回転を適用可能なのですが、誤差も累積してしまうので、ユーザー操作の開始時点まで毎回巻き戻しています。
続いて、タッチ位置から拡大率を計算して範囲チェック後拡大率をMatrixに適用します。
Matrixを使ったイメージの拡大縮小には数種類のメソッドが用意されていますが、今回使用しているのは基点を指定可能なMatrix#postScale(float sx, float sy, float px, float py)です。前の2つの引数がそれぞれ横・縦方向の拡大率で、後ろ2つの引数が拡大縮小の基点座標・・・拡大縮小した時に移動しない座標・・・となります。なお、今回の使い方ではImageViewのローカル座標系※で指定します。このメソッドはMatrixが変更されるとtrueを返してくれますので、変更された場合のみ続きの処理を行います。
なお、この時点では自前のMatrixを変更しただけで、ImageView自体には適用されていません。ImageView様にMatrixにしたがって表示するようにお願いするには、ImageView#setImageMatrixを呼び出します。APIレファレンスにはこのメソッドの説明は1行もありませんね。それぐらいは読んで分かれよって偉大な魔法使いの思し召しかな?
※ローカル座標:それぞれのViewの左上を(0,0)とし、右方向および下方向が正となる座標系です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/** * 拡大縮小処理 */ private final boolean processZoom(MotionEvent event) { // MatrixをsetState時点(拡大縮小操作の開始時点)まで巻き戻す restoreMatrix(); // 現在Matrixに設定してある拡大率を取得 final float currentScale = getMatrixScale(); // タッチ位置から拡大率を計算 final float scale = calcScale(event); // 今回の変更適用後の拡大率を計算 final float tmpScale = scale * currentScale; if (tmpScale < mMinScale) { // 適用後の拡大率が小さくなりすぎた時はスキップ return false; } else if (tmpScale > mMaxScale) { // 適用後の拡大率が大きくなりすぎた時もやっぱりスキップ return false; } // 拡大の中心位置を指定して拡大率を適用します if (mImageMatrix.postScale(scale, scale, mPivotX, mPivotY)) { // Matrixが変更された時, 変更済みフラグをtrueに設定(Matrixキャッシュ更新指示) mImageMatrixChanged = true; // ImageView様にMatrixの適用をお願い super.setImageMatrix(mImageMatrix); } return true; } |
拡大率は座標そのものではなく、座標の差(タッチ位置間の距離)を使って計算します。三平方の定理ってやつですね。
x,y座標それぞれの差の2乗和の平方根ってことで、普通であればこんな風に書きます。
1 |
final float distance = (float)Math.sqrt(dx * dx + dy * dy); |
でも実はMathクラスには同じ計算を高速で誤差も少なく計算してくれる便利なメソッドが有るのです。それがMath#hypotです。上式の右辺の計算を一発でしてくれます。
Math#hypotを使うとこうなります。
1 |
final float distance = (float)Math.hypt(dx, dy); |
ということで、拡大率の計算はこちら。ピンチによる拡大縮小の定番ですね。
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * タッチ位置間の距離の変化から拡大率を計算する */ private final float calcScale(MotionEvent event) { // x,yそれぞれの座標の差を計算 final float dx = event.getX(0) - event.getX(1); final float dy = event.getY(0) - event.getY(1); // 2点間の距離を計算 final float distance = (float)Math.hypot(dx, dy); // ズーム開始時のタッチ間距離に対する比を拡大率とする return distance / mTouchDistance; } |
引き続いて、タッチ&ドラッグによるイメージの移動です。タッチ位置から移動距離を計算してMatrixに適用。Matrxiが変更されればImageView様にお願いします。
コアとなる部分はこんな感じで、拡大縮小とほとんど同じになります。違うのはMatrix#postScaleの代わりにMatrix#postTranslateを呼び出すことです。
このメソッドでもMatrixが実際に変更された場合にtrueを返してくれますので、Matrixが変更された時だけ処理を続けます。
1 2 3 4 5 6 7 8 9 |
if ((dx != 0) || (dy != 0)) { // Matrixに移動を適用する if (mImageMatrix.postTranslate(dx, dy)) { // Matrixが変更された時, 変更済みフラグをtrueに設定(Matrixキャッシュ更新指示) mImageMatrixChanged = true; // ImageView様にMatrixの適用をお願いする super.setImageMatrix(mImageMatrix); } } |
そして最後は回転。こんな感じです。流石に見飽きて来ましたよね。
回転角度を計算した後、呼び出すのがMatrix#postRotate(float degrees, float px, float py)になるだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private final boolean processRotate(MotionEvent event) { if (checkTouchMoved(event)) { // restore the Matrix restoreMatrix(); mCurrentDegrees = calcAngle(event); mIsRotating = Math.abs(((int)(mCurrentDegrees / 360.f)) * 360.f - mCurrentDegrees) > EPS; if (mIsRotating && mImageMatrix.postRotate(mCurrentDegrees, mPivotX, mPivotY)) { // when Matrix is changed mImageMatrixChanged = true; // apply to super class super.setImageMatrix(mImageMatrix); return true; } } return false; } |
タッチ位置から回転角度を求めるのはこちらになります。
2点タッチ位置をベクトルとみなして、最初にタッチした時のベクトルと、現在のタッチから求めたベクトル間の角度を求めます。
ベクトルとして扱うには1本目の指と2本目の指を区別する必要がありますので、それぞれのIDをmPrimaryIdフィールドとmSecondaryIdフィールドに保存してあります。
2ベクトル間の角度の計算って今でも高校生で習うのかな?
ベクトルZa=(x0,y0)とベクトルZb=(x1,y1)のなす角度=Φとすると、
$latex \cos \; \phi =\frac{Z_{a}Z_{b}}{\left| Z_{a} \right|\left| Z_{b} \right|}=\frac{\left( x_{0}x_{1}\; +\; y_{0}y_{1} \right)}{\sqrt{\left( x_{0}^{2}\; +\; y_{0}^{2} \right)\left( x_{1}^{2}\; +\; y_{1}^{2} \right)}} &s=2$なので、
$latex \phi =\cos ^{-1}\left( \cos \; \phi \right) &s=2$から角度Φを計算します。
ただし、cos-1(Math#acos)の結果は0-π[ラジアン](0〜180[度])なので、
ZaとZbの外積を使って0±2π[ラジアン](0±360[度])に拡張します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
private static final float TO_DEGREE = 57.2957795130823f; // = (1.0f / Math.PI) * 180.0f; private final float calcAngle(MotionEvent event) { final int ix0 = event.findPointerIndex(mPrimaryId); final int ix1 = event.findPointerIndex(mSecondaryId); float angle = 0.f; if ((ix0 >= 0) && (ix1 >= 0)) { // 回転開始時のタッチ位置からタッチベクトルを求める // 本当は毎回計算する必要はありませんが、ここでしか使わないのでフィールドとしては保存してません final float x0 = mSecondX - mPrimaryX; final float y0 = mSecondY - mPrimaryY; // 現在のタッチ位置からタッチベクトルを求める final float x1 = event.getX(ix1) - event.getX(ix0); final float y1 = event.getY(ix1) - event.getY(ix0); // 回転開始時のタッチベクトルと現在のタッチベクトルのなす角を計算 // 内積と外積は展開して書いても良いですがわかりやすいように別メソッドとして呼び出してます final double s = (x0 * x0 + y0 * y0) * (x1 * x1 + y1 * y1); final double cos = dotProduct(x0, y0, x1, y1) / Math.sqrt(s); angle = TO_DEGREE * (float)Math.acos(cos) * Math.signum(crossProduct(x0, y0, x1, y1)); } return angle; } /** * ベクトル(x0,y0)とベクトル(x1,y1)の内積を計算 */ private static final float dotProduct(float x0, float y0, float x1, float y1) { return x0 * x1 + y0 * y1; } /** * ベクトル(x0,y0)とベクトル(x1,y1)の外積を計算 */ private static final float crossProduct(float x0, float y0, float x1, float y1) { return x0 * y1 - x1 * y0; } |
回転をしようとするといきなり数式が出て来ますが避けては通れません。こんなもんだと諦めましょう。
拡大縮小・移動に回転のコア部分は以上です。やったやった出来た〜って大喜びしたいところですが、肝心なところがいっぱい抜けてますね。タッチ操作とか移動範囲のチェックとか。でも長くなってきたので次回。お疲れ様でした。