イメージの拡大縮小・移動・回転はできるようになったので、今回はタッチによって操作をする方法について書きます。
まずはZoomImageViewの操作方法を決めます。本当は一番最初に決めてたんだけどね(^_^;)
- 1本指でタッチ&ドラッグすると、イメージを移動する。
- 2本指(以上)でタッチ&ピンチすると、イメージを拡大縮小する。
- 2本指でタッチ&ホールド(動かさずに長押し)してからタッチ位置を回すように動かすと、イメージを回転する。回転可能になるとフィードバックのために、表示しているイメージの色を一定時間反転させる
- 1本指でタッチ&ホールド(動かさずに長押し)すると、拡大縮小・移動・回転をリセットして最初に表示した状態に戻す。やっぱり元に戻せないと不便ですからね。
この操作方法からZoomImageViewの取るべき状態は、
- 何も操作をしていない
- イメージの移動操作中
- イメージの拡大縮小操作中
- イメージの回転操作中
の少なくとも4状態が有るのがわかりますね。それぞれの状態を表す定数を定義しちゃいましょう。
1 2 3 4 |
private static final int STATE_NON = 0; private static final int STATE_DRAGING = 2; private static final int STATE_ZOOMING = 4; private static final int STATE_ROTATING = 5; |
数字の1と3が抜けてますね。なぜでしょう?
実は上の4つの状態以外にも中間状態を存在させた方が処理が少し楽になるのです。
- 1つ目のタッチイベントが来たけどシングルタッチなのかマルチタッチなのか長押しなのかそのままドラッグするのかわからない状態、ユーザーの操作待ちの状態
- マルチタッチイベントが来たけどピンチ操作で拡大縮小するのか長押しして回転させたいのかわからない状態、ユーザーの操作待ちの状態
の2つの状態を追加しましょう。
1 2 |
private static final int STATE_WAITING = 1; private static final int STATE_CHECKING = 3; |
もっと細かく分けることも可能ですが細かすぎるのも面倒になるだけですので、今回は初期化のための-1を加えた7状態を使うことにします。
次にどの状態の時にどんなイベントが起きると状態遷移が起こるかをまとめてみましょう。
状態遷移表
状態 | #init呼び出し | タッチ操作開始 | マルチタッチ操作開始 | タッチ位置が変化した | キャンセルした | 指を離した | マルチタッチで指を離した | 長押し時間経過 |
---|---|---|---|---|---|---|---|---|
不定(-1) | STATE_NONへ | ---- | ---- | ---- | --- | --- | --- | --- |
STATE_NON | STATE_NONへ | STATE_WAITINGへ | --- | --- | --- | --- | --- | --- |
STATE_WAITING | STATE_NONへ | (STATE_WAITINGへ) | STATE_WAITING解除 STATE_CHECKINGへ | STATE_WAITING解除 STATE_DRAGINGへ | STATE_WAITING解除 STATE_NONへ | STATE_WAITING解除 STATE_NONへ | STATE_NONへ | RESET実行 STATE_NONへ |
STATE_DRAGING | STATE_NONへ | (STATE_WAITINGへ) | STATE_CHECKINGへ | 移動実行 | STATE_NONへ | STATE_NONへ | STATE_NONへ | --- |
STATE_CHECKING | STATE_NONへ | (STATE_WAITINGへ) | --- | STATE_ZOOMINGへ | STATE_NONへ | STATE_NONへ | STATE_NONへ | STATE_ROTATINGへ |
STATE_ZOOMING | STATE_NONへ | (STATE_WAITINGへ) | --- | 拡大縮小実行 | STATE_NONへ | STATE_NONへ | STATE_NONへ | --- |
STATE_ROTATING | STATE_NONへ | (STATE_WAITINGへ) | --- | 回転実行 | STATE_NONへ | STATE_NONへ | STATE_NONへ | --- |
MotionEvent | --- | ACTION_DOWN | ACTION_POINTER_DOWN | ACTION_MOVE | ACTION_CANCEL | ACTION_UP | ACTION_POINTER_UP |
こういう風に状態とイベント・アクションを表としてまとめたものを状態遷移表と呼んだりします。
今回の操作方法を実現する方法は色々あります。もちろんGestureDetectorを使ってもいいんですが、状態遷移がわかりにくくなってしまうので今回はonTouchEvent内で自前で実装します。
まずは、他のアプリ等と操作感を一致させるために、長押し時間を端末の設定から取得するようにします。
1 2 |
private static final int CHECK_TIMEOUT = ViewConfiguration.getTapTimeout() + ViewConfiguration.getLongPressTimeout(); |
これでCHECK_TIMEOUT定数に長押し時間が取得出来ました。この値は例えばGestureDetectorを使った時と同じになります。
続いてユーザーが画面にタッチした時のイベント処理を行うメソッド、onTouchEventを載せます。
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
@Override public boolean onTouchEvent(MotionEvent event) { // if there is no image, leave to super class if (!hasImage()) return super.onTouchEvent(event); final int actionCode = event.getActionMasked(); // >= API8 switch (actionCode) { case MotionEvent.ACTION_DOWN: // single touch startWaiting(event); return true; case MotionEvent.ACTION_POINTER_DOWN: { // start multi touch, zooming/rotating switch (mState) { case STATE_WAITING: removeCallbacks(mWaitImageReset); case STATE_DRAGING: if (event.getPointerCount() > 1) { startCheck(event); return true; } break; } break; } case MotionEvent.ACTION_MOVE: { // moving with single and multi touch switch (mState) { case STATE_WAITING: if (checkTouchMoved(event)) { removeCallbacks(mWaitImageReset); setState(STATE_DRAGING); return true; } break; case STATE_DRAGING: if (processDrag(event)) return true; break; case STATE_CHECKING: if (checkTouchMoved(event)) { startZoom(event); return true; } break; case STATE_ZOOMING: if (processZoom(event)) return true; break; case STATE_ROTATING: if (processRotate(event)) return true; break; } break; } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: removeCallbacks(mWaitImageReset); removeCallbacks(mStartCheckRotate); resetColorFilter(); case MotionEvent.ACTION_POINTER_UP: setState(STATE_NON); break; } return super.onTouchEvent(event); } |
一部状態遷移表に載ってない処理があったりしますが、概ね状態遷移表に従っているのがわかりましたか?簡単ですね(行に色を付けているところが発生したイベントです)。
ところで、ところどころで謎のメソッドView#removeCallbacksを呼び出しているに気づきましたか?
これが長押しを検出するための方法の1つなんです。
皆さんは、長押しの検出ってどうすると思いましたか?例えば、最初にタッチした時の時間を保存しておいて、次のイベントが来た時に時間を比較して、長押しかどうかを判定しようと考えた人もいるのでは無いでしょうか。
でも、実はonTouchEventはタッチ状態が変化した時、つまりタッチした時、タッチ位置が変化した時、タッチをやめた時などにしかイベントが発生しません。
つまり長押ししているだけではどんなに時間がたってもonTouchEventは来ないのです(GestureDetectorを使っていれば、今回の長押しの検出方法と同様の方法でonLongPressコールバックを呼び出してくれます)。
そこでどうするかというと、最初のACTION_DOWN(シングルタッチを開始した時)やACTION_POINTER_DOWN(マルチタッチを開始した時)に、一定時間後に実行してもらうようにお願いするのです。
例えば#startWaitingではこんな風にお願いしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private WaitImageReset mWaitImageReset; private final void startWaiting(MotionEvent event) { mPrimaryId = 0; mSecondaryId = -1; mPrimaryX = mSecondX = event.getX(); mPrimaryY = mSecondY = event.getY(); if (mWaitImageReset == null) mWaitImageReset = new WaitImageReset(); postDelayed(mWaitImageReset, CHECK_TIMEOUT); setState(STATE_WAITING); } /** * resetを呼び出すためのRunnable */ private final class WaitImageReset implements Runnable { @Override public void run() { reset(); } } |
ここで出てきたView#postDelayedにRunnable(WaitImageResetクラスのインスタンス)と遅延時間(ここではCHECK_TIMEOUT)を渡すことで、一定時間後にWaitImageReset#runが実行されます。また、長押し時間を経過する前に例えば別のイベントが発生して状態遷移する場合には、お願いを取り消す必要が有ります。それがView#removeCallbacksの呼び出しになります。
ちなみに、既に実行間終了してしまっているRunnableインスタンスを引数にしてView#removeCallbacksを呼び出しても無視されるだけでエラーにはなりませんし、同じRunnableインスタンス使って複数回お願いすることも出来ます。
同じように、マルチタッチした際には回転開始かどうかの確認を行います。
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 38 39 40 41 42 |
private final void startCheck(MotionEvent event) { if (event.getPointerCount() > 1) { // primary touch mPrimaryId = event.getPointerId(0); mPrimaryX = event.getX(0); mPrimaryY = event.getY(0); // secondary touch mSecondaryId = event.getPointerId(1); mSecondX = event.getX(1); mSecondY = event.getY(1); // 2つのタッチ位置間の距離を計算する final float dx = mSecondX - mPrimaryX; final float dy = mSecondY - mPrimaryY; final float distance = (float)Math.hypot(dx, dy); if (distance < MIN_DISTANCE) { // タッチした距離が短すぎる場合には無視する return; } mTouchDistance = distance; // 2本指の中間座標を基点(不動点)として保存しておく mPivotX = (mPrimaryX + mSecondX) / 2.f; mPivotY = (mPrimaryY + mSecondY) / 2.f; // 回転しようとしているかの確認のためのRunnableを後で実行してもらえるようにお願いする if (mStartCheckRotate == null) mStartCheckRotate = new StartCheckRotate(); postDelayed(mStartCheckRotate, CHECK_TIMEOUT); setState(STATE_CHECKING); // start zoom/rotation check } } private final class StartCheckRotate implements Runnable { @Override public void run() { if (mState == STATE_CHECKING) { // 回転状態へ遷移させる setState(STATE_ROTATING); // 回転可能状態になったことをフィードバックする callOnStartRotationListener(); } } } |
こんな感じですね。同じようにRunnableを生成した後View#postDelayedを呼び出しています。
そろそろ疲れてきたので今回はここまで。おつかれさまでした。