edo1z blog

プログラミングなどに関するブログです

AndroidSDK タッチイベント onTouchEventの研究

Androidアプリはタッチイベントによってことが進むので、タッチイベントの正確な把握は必須であり、基本中の基本だ。時間をかけてじっくり研究することは必要なことだ。まだ全体像がAndroidSDKはよく分かっていないので、間違った記載があると思いますので、ご指摘いただければ幸いです。

http://goo.gl/1AcZh
この記事によると、

タッチイベントを取得するには、ActivityクラスのonTouchEvent()をオーバーライドします。引数には、MotionEventのインスタンスが渡されます。

とある。
また、

MotionEventは、getAction()を呼び出すことで、タッチアクション(DOWN/UP/MOVE/CANCEL)、getEventTime()を呼び出すことで、イベント発生時刻(ms)、getX()、getY()を呼び出すことで、タッチされたx、y座標、を取得することができます。

とある。

指一本しか使わない場合


指一本しか使わない場合はかなり単純に実装できそうだ。例えば、どこでもいいからユーザーがタッチすると、ジャンプするボールをつくることにしよう。長くタッチした場合にジャンプ力を増したりするケースが多いと思うが、今回はそのようなことは考慮しないことにしよう。タッチしたらジャンプするだけだ。

サンプルコードは下記になります。黒い画面に白いボールが一つ表示され、タッチするとボールが跳ねます。

package net.npaka.helloworld;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.MotionEvent;

public class HellowView extends SurfaceView implements
 SurfaceHolder.Callback,Runnable{

 private int ww = 480;
 private int wh = 762;
 private Thread thread;
 private SurfaceHolder holder;
 private Paint paint = new Paint();
 private Ball ball = new Ball();

 public HellowView(Context context){
  super(context);

  holder = getHolder();
  holder.addCallback(this);
  holder.setFixedSize(ww,wh);

  paint.setAntiAlias(true);
  paint.setStyle(Paint.Style.FILL);
  paint.setColor(Color.argb(255,255,255,255));
 }

 public void surfaceCreated(SurfaceHolder holder){
  thread = new Thread(this);
  thread.start();
 }

 public void surfaceChanged(SurfaceHolder holder,
   int format,int w,int h){
 }

 public void surfaceDestroyed(SurfaceHolder holder){
  thread = null;
 }

 public void run(){
  Canvas canvas;
  while(thread!=null){
   canvas = holder.lockCanvas();
   canvas.drawColor(Color.BLACK);
   ball.view(canvas);
   holder.unlockCanvasAndPost(canvas);

   try{
    Thread.sleep(10);
   }catch(Exception e){
   }
  }
 }

 @Override
 public boolean onTouchEvent(MotionEvent event){
  ball.jumpFlag = true;
  return true;
 }

 class Ball{
  int r = 20;
  int x = r*2;
  float y = wh-r;
  float vy = -8;
  float g = 0.2F;
  boolean jumpFlag = false;

  public void view(Canvas canvas){
   canvas.drawCircle(x, y, r, paint);
   if(jumpFlag){
    y += vy;
    vy += g;
    if(y>=wh-r){
     y = wh-r;
     vy = -8;
     jumpFlag = false;
    }
   }
  }
 }
}


上記サンプルコードでタッチイベントを取得している部分は下記になります。

@Override
 public boolean onTouchEvent(MotionEvent event){
  ball.jumpFlag = true;
  return true;
 }


何らかのタッチイベントが発生したら、ball.jumpFlagをtrueにするようにしています。jumpFlagがtrueの場合にballはジャンプをするように設定されています。このような単純なタッチと動作の関連で問題ないようであれば、このような単純なコードで実装が可能です。但し、この場合、タッチした場合でも、ムーブした場合でも、タッチを離した場合でも、あるいは2本目の指でタッチした場合でも、何でもかんでもタッチイベントが発生した場合は全て反応することになります。すなわち、複数の指のタッチを別々に感知して動作を決定する場合や、タッチを離したときに別の動作をする(あるいは動作を中止する)場合などは、当然ながらもう少し複雑なコードを記載する必要があります。

タッチしたときのみジャンプし、タッチし続けるとジャンプ力が増す


Scooter Heroというゲームがあります。これはまさにタッチしたときのみジャンプし、タッチし続けるとジャンプ力が増します。あとジャンプ中にタッチすると、ジャンプ中の状態からさらに少しジャンプ(浮かぶ)します。このような場合は、上記のような超簡易的実装では難しくなります。タッチしたらジャンプし、タッチ中の時間に応じてジャンプの時間を調整します。タッチ中に別の指でタッチされてもそれは無視します。ジャンプ中に再度タッチした場合はそれを感知します。しかしジャンプ中ジャンプはタッチ中の時間にジャンプの時間が左右されることはないものとします。こういった実装が必要になります。

AndroidリファレンスのMotionEventを見ると、Eventの種類が載っています。onTouchEventの引数に渡されるMotionEventのインスタンスが保持している定数の種類になります。getActionで引っぱってこれる定数は、下記のようです。しかし、getAction()で引っぱってこれるのは、下記定数の数値(int)バージョンのようです。例えば、ACTION_DOWNは0です。

ACTION_CANCEL
ACTION_DOWN
ACTION_HOVER_MOVE
ACTION_MOVE
ACTION_OUTSIDE
ACTION_SCROLL
ACTION_UP
ACTION_POINTER_DOWN
ACTION_POINTER_UP

よって、最初の指でタッチしたときのみジャンプさせる場合は、下記のようなコードになります。

@Override
 public boolean onTouchEvent(MotionEvent event){
  int action = event.getAction();
  if(action == 0) ball.jumpFlag = true;
  return true;
 }


さらに、ジャンプ中に再度タッチした場合のジャンプ中ジャンプを実装すると下記になります。

@Override
 public boolean onTouchEvent(MotionEvent event){
  int action = event.getAction();
  if(action == 0){
   if(ball.jumpFlag == true) ball.jumpFlag2 = true;
   else ball.jumpFlag = true;
  }
  return true;
 }

 class Ball{
  int r = 20;
  int x = r*2;
  float y = wh-r;
  float vy = -8;
  float g = 0.2F;
  boolean jumpFlag = false;
  boolean jumpFlag2 = false;
  boolean jump2finFlag = false;

  public void view(Canvas canvas){
   canvas.drawCircle(x, y, r, paint);
   if(jumpFlag){
    if(jumpFlag2){
     if(!jump2finFlag){
      vy = -6;
      jump2finFlag = true;
     }
    }
    y += vy;
    vy += g;
    if(y>=wh-r){
     y = wh-r;
     vy = -8;
     jumpFlag = false;
     jumpFlag2 = false;
     jump2finFlag = false;
    }
   }
  }
 }


onTouchEventを修正して、jumpFlagがtrueの場合は、jumpFlag2をtrueにするようにしています。そして、Ballクラスを修正して、jumpFlag2とjump2finFlagを追加し、jumpFlag2がtrueの場合に一度だけ再度ジャンプするように設定しています。

さて、次はタッチし続けることでジャンプ力を増すようにします。最初の指がタッチし続ける場合に、そのタッチし続ける時間に応じてジャンプ力が増すようにします。最初の指は、primary pointerということで明確にAndroid SDKが把握してくれるので、結構簡単に実装ができます。考え方としては、ACTION_DOWNが発生してから、ACTION_UPが発生するまでの時間を計測するというものです。ACTION_DOWNもACTION_UPもprimary pointerのdown,upのみで発生するActionになるので、最初の指がタッチしたら必ずACTION_DOWNが発生し、最初の指がタッチをやめたら必ずACTION_UPが発生するはずです。 但し、懸念としては、ACTION_CANCELとACTION_OUTSIDEというのが気になります。これらは後で確認するとして、とりあえず、上記考え方でサンプルコードを修正してみます。

@Override
 public boolean onTouchEvent(MotionEvent event){
  int action = event.getAction();
  if(action == 0){
   if(ball.jumpFlag == true){
    ball.jumpFlag2 = true;
   }else{
    ball.jumpFlag = true;
    ball.touchTime = 1;
   }
  }else if(action == 1){
   ball.touchTime = 0;
  }
  return true;
 }

 class Ball{
  int r = 20;
  int x = r*2;
  float y = wh-r;
  float vy = -8;
  float g = 0.2F;
  boolean jumpFlag = false;
  boolean jumpFlag2 = false;
  boolean jump2finFlag = false;
  int touchTime = 0;

  public void view(Canvas canvas){
   canvas.drawCircle(x, y, r, paint);
   if(jumpFlag){
    paint.setColor(Color.argb(255,0,255,255));
    if(jumpFlag2){
     paint.setColor(Color.argb(255,255,255,0));
     if(!jump2finFlag){
      vy = -6;
      jump2finFlag = true;
     }
    }else if(touchTime > 0){
     paint.setColor(Color.argb(255,0,255,0));
     vy = -8;
     touchTime ++;
     if(touchTime >30){
      touchTime = 0;
     }
    }
    y += vy;
    vy += g;
    if(y>=wh-r){
     y = wh-r;
     vy = -8;
     jumpFlag = false;
     jumpFlag2 = false;
     jump2finFlag = false;
     paint.setColor(Color.argb(255,255,255,255));
    }
   }
  }
 }


これで操作を試した限りではうまくいっています。タッチをやめた(指を離した)ときに、なぜかACTION_UPが発生しなかった場合などは、永遠とタッチしている認識になってしまいますが、今回のサンプルコードでは、30フレーム以上タッチし続けた場合は、それ以降は意味をなさないので、特段の問題はありません。このサンプルコードの修正箇所のポイントとしては、タッチし続けている間は、タッチタイムが加算されていき、タッチタイムが1以上30未満の場合は、ずっと加速度-8をキープし続けるようにしています。よってタッチし続けるほど、一定期間はジャンプ力が増すことになります。ボールの色は、ジャンプ中が水色、加速度-8をキープ中が緑、二段ジャンプ中が黄色になるようにしています。

複数の指を使う場合

例えば、2本の指でWEBと同じように拡大縮小をしたりとか、あるいは画面にファミコンのコントローラーのボタンを表示して、タッチしたボタンに応じて上下左右に移動したり、ピストルを撃ったりする場合は、複数の指を使うことになります。これら機能を実装する場合は、各指のタッチしている座標などの取得も必要になります。

タッチ座標の取得

まずはタッチした座標の取得方法を研究します。
getX(),getY()でタッチイベントが発生した座標を取得できるということなので、下記コードを試します。

@Override
 public boolean onTouchEvent(MotionEvent event){
  int action = event.getAction();
  touchX = event.getX();
  touchY = event.getY();
  return true;
 }


これで座標を取得できますが、あくまでプライマリポインターの座標になるようです。2本目の指をタッチしても2本目の指がタッチした座標は取得できません。1本目の指を離したときも1本目の指が離された座標が取得されるだけであり、1本目を離して2本目を動かして初めて2本目の指の座標が取得されます。

getX(int pointerIndex)というのがあります。各指の座標はポインターインデックスを引数にとることでゲットできます。では、ポインターインデックスはどうやってゲットするかというと、下記のようになるようです。

//アクションインデックスの取得
int index = (action&MotionEvent.ACTION_POINTER_ID_MASK)>>
 MotionEvent.ACTION_POINTER_ID_SHIFT;
//int index=event.getActionIndex();//API Level 8以降


上記コードは、『Androidプログラミングバイブル』に載っていました。


とはいえ、例えばタッチしている各座標を全て取得するだけでよければ、アクションインデックス(ポインターインデックス)を上記のように取得する必要はありません。ポインターインデックスは下記のようになりますので、ポインターの数を把握してforで全部取得すればいいのであります。

pointerIndex => Raw index of pointer to retrieve. Value may be from 0 (the first pointer that is down) to getPointerCount()-1.


@Override
public boolean onTouchEvent(MotionEvent event){
 int cnt = event.getPointerCount();
 point = new PointF[cnt];
 for(int i=0;i<cnt;i++){
  point[i] = new PointF(event.getX(i),event.getY(i));
 }
 return true;
}


上記は、getPointerCountでポインターの数を把握し、PointFの配列であるpointをポインターの数で初期化し、ポインターの数だけ、各ポインターインデックスの座標をpointに格納している様です。これで、各ポインターの座標を取得できました。しかし、問題があります。問題は、タッチイベントが発生したポインターの座標を全て取得することです。これはすなわち、ACTION_UPやACTION_POINTER_UPが発生したポインターの座標も取得します。よって、タッチをやめたポインターの座標まで取得してしまうということです。これはゲームを作る場合において、問題になるケースが多いはずです。よって、上記サンプルコードをタッチをやめた場合は、座標を取得しないように修正します。

ACTION_POINTER_UPの数字は6(間違いでした)です。ACTION_UPの数字は1です。

※よってこのコードも間違いです。

@Override
public boolean onTouchEvent(MotionEvent event){
 int cnt = event.getPointerCount();
 action = event.getAction();
 int idx = 100;
 point = new ArrayList<PointF>();
 if(action == 1 || action == 6){
  idx = (action&amp;MotionEvent.ACTION_POINTER_ID_MASK)>>
  MotionEvent.ACTION_POINTER_ID_SHIFT;
 }
 for(int i=0;i<cnt;i++){
  if(i != idx) point.add(new PointF(event.getX(i),event.getY(i)));
 }
 return true;
}


これは間違いでした。なぜならgetAction()で取得できる数値は細かいからです。定数のACTION_POINTER_UPというのは、プライマリーではないポインターがアップする様を指すはずですが、actionは何本目のポインターかによって数値が違います。2本目は262、3本目は518といった具合で、全然6ではありませんでした。他の指が残っている状態の中でプライマリーポインターがアップすると、6になりました。よって上記コードですと結局1本目の指のアップしか検知しないことになります。指はせいぜい5本みておけばいいだろうという仮定の基に、検知するaction数を増やすのも手ですが、actionの数値を定数に変換する方法を探した方が良さそうです。

@Override
public boolean onTouchEvent(MotionEvent event){
 int action = event.getAction();
 int cnt = event.getPointerCount();
 int idx = 100;
 point = new ArrayList<PointF>();
 switch(action&amp;MotionEvent.ACTION_MASK){
 case MotionEvent.ACTION_UP:
 case MotionEvent.ACTION_POINTER_UP:
  idx = (action&amp;MotionEvent.ACTION_POINTER_ID_MASK)>>
  MotionEvent.ACTION_POINTER_ID_SHIFT;
 }
 for(int i=0;i<cnt;i++){
  if(i != idx) point.add(new PointF(event.getX(i),event.getY(i)));
 }
 return true;
}


これで、どの指でもアップの場合は座標取得をしなくなりました。しかし、何故かよく強制終了されます。なんでだろう??angry birdでタッチ操作は激しくやっても全然強制終了にならないのに。。ちょっと激しくタッチ操作をやると割とすぐに強制終了されてしまう。。。なんででしょうか?

一旦今作成しているコードを掲載ておこう。

package net.npaka.touchtest;
import java.util.ArrayList;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PointF;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.MotionEvent;

public class TouchTestView extends SurfaceView implements
  SurfaceHolder.Callback,Runnable{

  private int ww = 480;
  private int wh = 762;
  private Thread thread;
  private SurfaceHolder holder;
  private Paint paint = new Paint();
  private ArrayList<PointF> point = new ArrayList<PointF>();

  public TouchTestView(Context context){
   super(context);

   holder = getHolder();
   holder.addCallback(this);
   holder.setFixedSize(ww,wh);

   paint.setAntiAlias(true);
   paint.setStyle(Paint.Style.FILL);
   paint.setColor(Color.argb(255,0,255,255));
   paint.setTextSize(32);
  }

  public void surfaceCreated(SurfaceHolder holder){
   thread = new Thread(this);
   thread.start();
  }

  public void surfaceChanged(SurfaceHolder holder,
    int format,int w,int h){
  }

  public void surfaceDestroyed(SurfaceHolder holder){
   thread = null;
  }

  public void run(){
   Canvas canvas;
   while(thread!=null){
    canvas = holder.lockCanvas();
    canvas.drawColor(Color.BLACK);
    if(point.size()>0){
     for(int i=0;i<point.size();i++){
      canvas.drawText("x:"+point.get(i).x+"y:"+point.get(i).y, 0, 50+30*(i+2), paint);
      canvas.drawText(""+point.size(), 0, 80, paint);
     }
    }
    canvas.drawText("test", 0, 50, paint);
    holder.unlockCanvasAndPost(canvas);

    try{
     Thread.sleep(10);
    }catch(Exception e){
    }
   }
  }

  @Override
  public boolean onTouchEvent(MotionEvent event){
   int action = event.getAction();
   int cnt = event.getPointerCount();
   int idx = 100;
   point = new ArrayList<PointF>();
   switch(action&amp;MotionEvent.ACTION_MASK){
   case MotionEvent.ACTION_UP:
   case MotionEvent.ACTION_POINTER_UP:
    idx = (action&amp;MotionEvent.ACTION_POINTER_ID_MASK)>>
    MotionEvent.ACTION_POINTER_ID_SHIFT;
   }
   for(int i=0;i<cnt;i++){
    if(i != idx) point.add(new PointF(event.getX(i),event.getY(i)));
   }
   return true;
  }
}