Activityでタッチイベントを流しなおす
(これはフィクションです)
タブレット向けのすごろくアプリを作ります。デジタルとアナログの融合とかそういう企画らしくてリアルなコマを画面の上に置くらしいです。さいころはアプリのUIをタップして振ります。いつもはエミュレータ上でアプリを動かしながら開発をしていましたが、コマの試作品ができたのでタブレットでアプリを動かして遊んでみることになりました。しばらくは普通に遊べましたが、突然さいころのボタンがクリックに反応しなくなりました。なぜでしょうか。
(ここからはノンフィクションです)
このケースでは画面の上に置いたコマがタッチイベントを起こしている。コマのタッチイベントを無視しつつ、さいころ(やその他のクリッカブルなUI)のクリックを何とかして拾うにはどうすればいいだろうか。
クリックイベント?
そもそも普通のクリックイベントはどうなっているのだろうか。AndroidのタッチイベントはMotionEvent
というクラスで表現されている。
指で画面に触れたりその指を動かしたり、指を離したりするたびにMotionEvent
が生成される。アクションの種類やイベントが起きた時のタイムスタンプ、どのデバイスから発生したものかなどのデータが入っている。MotionEvent
の伝搬はざっくりいうと
Window -> Window.Callback.dispatchTouchEvent() -> View ((いろんなメソッドが呼ばれるけど割愛)) -> Window.Callback.onTouchEvent()
の順に流れていく。Window.Callback
はActivity
などが実装している。
いわゆる普通のクリックの際には次のような順でMotionEvent
が届けられる。
- 画面に指が触れると
actionMasked
がACTION_DOWN
であるMotionEvent
オブジェクトが発生してWindow
以下のView
に順番に渡される。 - クリッカブルな
View
が受け取るとそのView
のonTouchEvent()
がtrue
を返してくる(=タッチイベントを消費する、と呼ぶことにする)。これでそのView
が一連のタッチイベントを扱うことになる。 - 画面から指が離れると
actionMasked
がACTION_UP
であるMotionEvent
オブジェクトが発生して、1と同じように順番にView
に渡される(が、2でイベントを消費したView
以外は同じように無視することになる)。 - 2で
ACTION_DOWN
を消費したView
がACTION_UP
であるMotionEvent
オブジェクトを受け取って消費する*1と、クリック成立となりView.OnClickListener.onClick()
が呼ばれたりする。
ここでのポイントは、クリックとは一つのView
に対してACTION_DOWN
とACTION_UP
が連続で起きるということである。APIリファレンスからは、ACTION_DOWN
は一連のアクションの最初、ACTION_UP
は一連のアクションの最後のイベントにつくものということが読み取れる。その一方で、コマがタッチイベントを起こしているときにさいころをクリックすると何が起きているのか。
- 画面の上に置いたコマが
ACTION_DOWN
のMotionEvent
を発生させる。 - さいころに触れると、2本目の指が触れたことになるため
ACTION_POINTER_DOWN
のMotionEvent
が発生する。 - さいころから指を離すと、
ACTION_POINTER_UP
のMotionEvent
が発生する。
ACTION_POINTER_DOWN
は2本目以降の指が触れたとき、ACTION_POINTER_UP
は2本目までの指が離れたときに起きるアクションで、普通はインデクスと一緒にACTION_POINTER_DOWN(1)
などと書いたりする。MotionEvent.getAction()
だとアクション種別とインデクスが一緒になった値が取れるのでアクション種別だけとるためにMotionEvent.getActionMasked()
の方を使う。インデクスはMotionEvent.getActionIndex()
で取得する。
普通のクリックイベントと見比べると、さいころのView
に起きているイベントはACTION_POINTER_DOWN
とACTION_POINTER_UP
の組み合わせになっている。また、アクションの終端で起きるはずのACTION_DOWN
が起きていない。つまりこのジェスチャはまだ終わっていないことになっている。
こまったこまった
タッチが全く効かない、でもANRも起きない。普通はログにも出さないようなことだろうから、外から見ても何が起きているのか全然わからない。開発者メニューのタッチ位置を表示する設定を有効にすればわかるようになるかもしれないが、こういうことが起きることもあると知っていなければ気づくこともできない。
いろんな解決策があるのだろうが、今回は次の二点:
- さいころのUIへのクリックイベントが
ACTION_POINTER_DOWN
とACTION_POINTER_UP
になっており、View.OnClickListener
を使うような一般的な方法でクリックイベントを拾えない - コマが持ち上げられない限り一連のタッチイベントが完了しないためそもそもいろんなジェスチャのイベントが起きない
を問題ととらえ、これを解決する方法を考えた。
最初の方で、MotionEvent
の伝搬のイメージについて述べた。その中で、Window.Callback.dispatchTouchEvent()
がどのView
よりも先に呼ばれてMotionEvent
を受け取れること、そして、Activity
がWindow.Callback
のdispatchTouchEvent()
を実装しているという話をした。今回はここに着目して、このActivity.dispatchTouchEvent()
をオーバライドしてMotionEvent
を受け取り、条件によって新しいMotionEvent
を生成して流しなおすという戦略で解決を試みたい*2。
ナイーブな方法としては、
super.dispatchTouchEvent()
は、流したMotionEvent
がどのViewにも消費されなかったらfalse
を返す。これを利用して、ACTION_DOWN
のイベントがだれにも消費されなかったら保存しておく。ACTION_POINTER_DOWN
のイベントが流れてきたら末尾のイベント(MotionEvent.getActionEvent()
が指し示すもの)だけ抜き出す。MotionEvent.obtain()
を使って新たにオブジェクトを作り、super.dispatchTouchEvent()
に渡す。
というようなもので、これはエミュレータにおいて、2点タッチ(MacならCmd+クリック)でマウスカーソルじゃない方のポインタでクリックできるというのは一応確認している。今ちょっといろんな事情があってこれ以上の検証ができないのだが、詳しいことが分かれば続編がでるかもしれない。