こんにちは、Yahoo!天気・災害のエンジニア、大前です。 Android版Yahoo!天気アプリの開発を担当しています。 仕事はアプリ開発で、趣味もアプリ開発です。毎日楽しく開発しています!
Mix Leap Study #57 - iOS & Android勉強会 にて、Yahoo!天気アプリのリニューアルのお話をしました。
本稿では、そこでの話をベースに、技術的な内容についてもう少し詳しく説明してみようと思います。
Yahoo!天気アプリの大幅リニューアル
昨年12月にYahoo!天気アプリは大幅リニューアルを行いました。
ファーストビューでは一番見られている今日明日の天気を大きく表示し、 従来タブ切り替えが必要だった1時間ごとの予報も一緒に確認できるようにしています。 以前は48時間先までの予報でしたが、最大72時間分に拡大、 日の出と日の入の時間も確認できるようになりました。
従来8日間だけだった天気予報が17日間も表示されるようになりました。 東京オリンピックでは開会式の時点で閉会式の天気予報を確認できてしまいます。 ただ、長期予報となると予報の信頼性も気になるところですので、予報信頼度も合わせて表示しています。
まだ使ったことがないという方は、ぜひインストールしてみてください!
より良い仕様を決めるためのプロトタイプ開発
アプリの開発では、仕様を確定する前のアイデア出しの段階でも、 よくプロトタイプアプリを作り、実際に触ってみながらアイデアの練り込みを行っています。 頭の中で考えるだけでは、使用感などはよくわかりません。 実際に触れるアプリを作って検討すると、頭の中で考えているだけでは気づけなかったさまざまなことに気づけます。
アプリ開発では、プロトタイプ開発のコストが小さいので、アイデアが出ればすぐに作り、意見が出ればすぐに反映、これを繰り返します。 この段階では作ったものはすぐに捨ててしまうので、完全にスピード優先で他のことは二の次です。 このボタンを触るとクラッシュするのでそれ以外で試してみて、みたいなことも許容してしまいます。
最終的に仕様が決まったら、しっかりと設計して作り直します。
プロトタイプの検証で見つかった課題
早速ですが皆さん、Yahoo!天気アプリのこの部分、
どういう操作ができるかちょっとだけ考えてみてください。 どんな操作をすれば、どんな動きをすると思いますか? じっくり考える必要はありません。直感でイメージしてみてください。 ご自身のスマホでアプリを実際に触ってみていたけると良いと思います。
イメージできましたか?
実はこの部分、今日と明日を切り替えたり、 明後日の予報を表示する操作ができたりするのですが、気づきましたでしょうか?
操作できるとは思わなかった人、また、操作できるとしても今日明日が切り替わるとは思ってなかった人、 タップで操作できると思った人、フリックで操作できると思った人、いろいろあると思います。 どれが正解というものではありません。操作のイメージとは人によって意見が分かれる部分だ、ということです。
実はこの部分、はじめはタップで操作することしか想定していませんでした。
しかし、プロトタイプを作っていろいろな人に触ってもらったところ、 もちろんタップする人もいたのですが、フリックで切り替えられると思って操作してしまう人がいたのです。 1時間の部分をスクロールすると左右にアニメーションすることもあって、フリックすれば切り替わるんじゃないか? と連想させたのかもしれません。
直感的にフリックで操作できると思ってしまった人には、「タップで操作するんだよ」と伝えても、 次の操作のときにはついフリックをしてしまいます。 直感的にそう感じてしまったら、そう簡単に覆すことはできません。 しかもこの部分は、横スワイプでタブ切り替えができるようになっているため、 意図した操作ができないだけではなく、意図と違う結果になってしまうのです。
イメージ通りの操作ができない。イメージと違う結果になる。 というのは、使っているとすごくストレスになり、使いにくい、という印象を与えてしまいます。
フリックに対応させることは他の操作と特に矛盾するものではなかったので、 フリックでも操作できるようにするということに決まりました。
フリック操作なんて当たり前のことなのに、思いつかなかったのか? という意見もあるかもしれません。 確かに、これだけであれば検討段階で気づけたかもしれません。 しかし、なにもない状態から、頭の中だけで仕様を考えていたのでは、それ以外の何かを見落としていたかもしれません。
百聞は一見にしかず、百見は一触にしかず? 実際に目で見て、触ってみるというのは、 頭の中だけで考えていては気づけないことを短時間で気づかせてくれる、非常に良い方法です。
実装する上での難しさ
今日明日の部分は、タップとフリックで操作できるように仕様が決まりました。 この画面上で必要となる操作をまとめてみましょう。
画面全体は左右スワイプによって地点の切り替えが可能です。 また、全体は縦方向のスワイプによってスクロール可能です。 さらに、今日明日はタップでも、フリックでも今日明日を切り替えることができるようにすると決まりました。
タッチを受け取る座標は同じでも、 そこに何が表示されているか、ユーザーがどのような操作をしたかで、 複数の操作を適切に切り分けて実現する必要があります。
この切り分けが適切にできないと、ぎこちない動きになってしまったり、ユーザーに違和感を覚えさせ、使いにくい、という印象を与えてしまいます。 Androidで画面をタッチしたイベントがどのように伝達され、どのように処理するのか、詳細な仕組みをよく理解しておく必要があります。
適切な操作の切り分け
適切に切り分けるというのはどういうことなのか、もう少し掘り下げてみましょう。
タップについて、 タップがどういう操作なのか分からない、という人はほとんどいないでしょう。 言葉で具体的に説明してみると、画面を触り、すぐさま指を離す。 これを指を上下左右などに移動させないで行う。といったところでしょうか?
これをタッチセンサーレベルで見るとどうなるでしょう。 「指が画面に触れる」「指が移動する」「指が離れる」というイベントが順次発生します。 実は、タップでも座標の移動は発生しているのです。 (Androidはさまざまなデバイスがあり、タッチセンサーの感度もまちまちなので、移動が発生しない端末も存在します)
指が画面に触れる面積はセンサーの解像度よりもよっぽど大きいので、 指が触れ始めてから押し込まれるまでのわずかな変化でさえ、座標の変化として通知されてしまうためです。 また、タップするときの指の触れ方というのは、意外なほど個人差があり、 ややフリック気味に触れる人や、ぐっと力強く押し込むように触れる人などさまざまです。 当然、タッチセンサーで見たときの押し込まれている時間や移動量といった情報も違います。
個人差も含めて、わずかながら座標の移動は発生しているのですが、 このタップのときのわずかな座標の移動を、スワイプ操作として受け取り、スクロールさせてしまうと違和感が出てきます。 画面の動きとしてはほんの数ピクセルであっても「タップ」をしようとしたのに「スクロール」が発生した、という違和感ですね。
他の操作についても同様です。 横スワイプは、タッチセンサーから見ると、X軸方向だけの移動ではなく、Y軸方向にも移動が発生しますし、移動も完全な直線ではなく、円弧を描いたりします。 イベントとしては「指が画面に触れる」「指が移動する」「指が離れる」というイベントが発生します。 つまり、移動イベントの座標情報なり回数なりは違いますが、タップとほぼ同じなのです。 それでも、横スワイプの操作を受け付けたときは、タップのイベントを発生させてはいけませんし、 Y軸方向の移動があったとしても縦方向のスクロールを発生させてはいけません。 横スワイプと判定したら横スワイプだけを受け付け、他の操作が発生しないようにする必要があるのです。
このように、ユーザーが何かをしようとして操作をしたとき、 その操作が何なのかを適切に判定するとともに、 その意図する操作だけを受け取り、それ以外の操作は受け付けないように制御する必要があるのです。
Viewの階層に応じた役割分担
まずは、Viewの階層に応じた役割分担を考えます。 もちろん画面全体でタッチイベントを監視して判定、という作り方もできなくはないのですが、 非常に複雑な実装になってしまいますし、少し表示を変更するだけで操作処理の変更が必要になるなど、柔軟性にかける実装になっています。
Viewの階層全体を見て、どこにどのような責務を設定すればよいかを考えていきます。 プログラミングの基本、分割統治と責務の明確化です。
ここでは、以下のような役割に決めました。(操作に関係のない部分は省略しています)
下から順に
- A. タブ切り替えのために横スワイプを受け取るView
- B. 縦スクロールのために縦スワイプを受け取るView
- C. 今日明日切り替えのために横フリックを受け取るView
- D. 今日明日切り替えのためにタップを受け取るView
という役割分担になっています。 今日明日の切り替えは、「今日」「明日」をあわせた全体でフリックを監視します。 どこでフリックをしても同じ結果になるためです。 一方タップは、タップした方へ切り替えるという動作になるので、「今日」と「明日」それぞれのViewで操作を監視します。
Androidのタッチイベント伝達のしくみ
Androidのシステムではタッチイベントがどのように扱われるかを説明します。
ViewはdispatchTouchEvent
というメソッドを持っています。
このメソッドは通常overrideすることはありません。
Viewは親からこのメソッドでイベント受け取り、タッチに関するすべての処理を行います。
OnClickListener
やOnTouchListener
が登録されている場合、このメソッドの中から呼び出されます。
また、View自体もonTouchEvent
というメソッドを持っており、これもdispatchTouchEvent
から呼び出されます。
通常Viewのタッチイベントハンドリングをカスタマイズする場合はonTouchEvent
をoverrideします。
ViewGroup、一般にLayoutという名前がついていますね。これはViewを継承していて、おおむね同じなのですが、
dispatchTouchEvent
の中で、子Viewへイベントを伝達するところと、
onInterceptTouchEvent
というメソッドが追加されています。
onInterceptTouchEvent
が最初にコールされ、子ViewのdispatchTouchEvent
がコールされ、最後にonTouchEvent
コールされます。
Viewの階層全体で見てみると、図の(1)〜(5)のような順序で伝達されていきます。
ただし、onTouchEvent
で、最初に戻り値をtrue
にすると、
それ以降のonTouchEvent
はコールされなくなります。
図の(3)でtrue
を戻したとすると、(4)(5)のonTouchEvent
はコールされなくなります。
また、(3)でfalse
を戻し、(4)でtrue
を戻したとすると、(3)は一度指が離れて、次のタッチが発生するまでコールされなくなります。
このように、Viewの階層の中でイベント処理の主導権を取ることができるのは一つだけという仕組みになっています。
Viewが主導権を持っている場合は、その親に当たるViewはonInterceptTouchEvent
がコールされます、
親のViewはここでイベントを監視できます。
そしてonInterceptTouchEvent
でtrue
を戻すと、子Viewから主導権を奪うことができます。
さらに、requestDisallowInterceptTouchEvent
というメソッドがあり、
これは親Viewに対して、子Viewから主導権を奪わないように要求することができます。
このれら仕組みを使って、操作の切り分けを実装するわけです。
Viewの階層ごとのやるべき処理
役割分担を決めたそれぞれのViewでやるべきことをざっとまとめます。
- A. タブ切り替えのために横スワイプを受け取るView
- 横スワイプであると判定されるまでタッチイベントを監視する
- 横スワイプであると判定されたら、子Viewから主導権を奪う
- B. 縦スクロールのために縦スワイプを受け取るView
- 縦スワイプであると判定されるまでタッチイベントを監視する
- 縦スワイプであると判定されたら、子Viewから主導権を奪い、親に主導権奪取を禁止する
- C. 今日明日切り替えのために横フリックを受け取るView
- タッチ開始時点で、親Aに主導権奪取を禁止する
- 横スワイプであると判定されるまでタッチイベントを監視する
- 横スワイプであると判定されたら、子Viewから主導権を奪い、親に主導権奪取を禁止する
- D. 今日明日切り替えのためにタップを受け取るView
- 親にイベントを奪われないでタッチが終了したらタップと判定
ちょっと特殊な処理が必要なのはCですね。 親Aが横方向のスワイプを監視しているからです。 横スワイプと横フリックの判定は同じですので、何もしないで横フリックの監視をしていると、 どちらも同時に自分の監視している動作だと判定してしまいます。 そのため、タッチの開始時点で、Aの親に対して、主導権奪取禁止を要求します。
実際の処理
それでは、前述の特殊な処理をしているCの横フリックを監視している部分のソースコードを見てみましょう。
今回の説明に必要な部分だけを抜粋したものが以下です。
private val touchSlop: Float = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startX = event.x
findParentViewPager(parent)
?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
val dx = event.x - startX
if (abs(dx) >= touchSlop) {
parent.requestDisallowInterceptTouchEvent(true)
listener?.invoke(dx < 0)
return true
}
}
}
return false
}
MotionEvent.ACTION_DOWN
が最初にタッチが始まったイベントですね。
ここで最初の座標を記録するとともに、親のViewPager
、突然出てきましたが、AのViewです。
これを探して、requestDisallowInterceptTouchEvent
をコールして主導権奪取を禁止しています。
MotionEvent.ACTION_MOVE
は、座標が移動したイベントです。
ここではX軸方向の移動量を監視し、一定量を超えたときに横方向のフリックだと判定して、
親に主導権奪取を禁止して、フリックイベントを通知し、戻り値true
を戻して、子Viewから主導権を奪っています。
このフリックだと判定したしきい値は、
ViewConfiguration#scaledTouchSlop
の値を使っています。
このメソッドは非常に短くシンプルな内容ですが、タッチ操作を適切に切り分けるためのノウハウが詰まっています。
補足説明
会場でも質問がありましたので、ここでViewConfiguration#scaledTouchSlop
について補足説明です。
Kotlinではフィールドアクセスになっていますが、Javaから見るとgetScaledTouchSlop()
というメソッドです。
戻り値の単位はピクセルで、具体的な値としては、8dp
をピクセルに変換した値が戻る場合が多いようです。
しかし、タッチパネルの感度やデバイスの大きさなどに依存するため、必ずしもこの値ではなく、異なる値になる場合があります。
アプリの開発では具体的にどのような値になるのかは意識しないで、メソッドの戻り値をそのまま使えばよいです。
ViewConfiguration
はUIで使用される、いろいろなタイムアウトやサイズ、距離に関する値を提供してくれるクラスです。
例えば、ロングタップと判定される押下時間などもこのクラスから取得できます。
まとめ
プロトタイプ開発を通して、できるだけ多くの人が直感的に操作できるように仕様を決め、 そのイメージ通りの操作を実現するために、View階層での役割分担、そして、複数の操作を適切に切り分け、受け付ける実装を説明しました。
動作がイメージからほんの少しずれるだけで違和感を感じさせ、使いにくさにつながってしまいます。 そのために、ユーザー操作の処理に対する深い理解と、丁寧な実装が必要です。
Yahoo!天気アプリは大幅リニューアルを行いましたが、 リリース後、ユーザーからもたくさんの意見をいただいておりますし、 われわれも、これが完成だとは思っていません。 これからも、より使いやすいアプリを目指して改善を続けていきます。
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました