(これはフィクションです)
タブレット向けのすごろくアプリを作ります。デジタルとアナログの融合とかそういう企画らしくてリアルなコマを画面の上に置くらしいです。さいころはアプリの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+クリック)でマウスカーソルじゃない方のポインタでクリックできるというのは一応確認している。今ちょっといろんな事情があってこれ以上の検証ができないのだが、詳しいことが分かれば続編がでるかもしれない。