最近Androidのレイアウトの勉強の取り組みとしてTwitterを模倣して実装する学習をやっています。その際、アイコンのImageViewをタップした時に、思うようにタップできないことがあります。
Gif画像では、カードの右側の逆三角形アイコンをタップしようとしていますが、ちょっとずれただけでカードへのタップイベントだと認識されてフィーリングが良くないの図です。
実際に配置しているImageViewがこれなのですが、小さい三角形のアイコンなので、幅22dpを指定しています。
<ImageView
android:id="@+id/imageAction"
android:layout_width="22dp"
android:layout_height="22dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_arrow_drop_down" />
基本的にユーザーがタップした時に、違和感なくタップが有効的なサイズは48dpです。そのため、三角のアイコンをタップしたと思ったらカードをタップしたことになったようなことが多発します。
このように、表示上ImageViewは小さく表示したい。だけどタップ可能な範囲は最低48dp確保したい時に使うのが、TouchDelegateです。
TouchDelegateとは
TouchDelegateは android.view.TouchDelegate
クラスで、Viewに最初から入っているので特別何か用意しなくても使うことができます。
タップイベントを別のレイアウトに委譲することができるのですが、簡単に言うと、別のViewがタップされたときに自分がタップされたことにできるわけです。
今回のような場合は、22dp幅のImageViewをFrameLayoutなどのViewGroupで48dp以上になるように囲ってあげて、TouchDelegateを適用させてあげればいいような感じです。
<FrameLayout
android:layout_width="48dp"
android:layout_height="48dp">
<ImageView
android:id="@+id/imageAction"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_arrow_drop_down" />
</FrameLayout>
ちょっとわかりづらいかもですけど、右側のImageViewの周りの要素が48dp*48dpのFrameLayoutです。
また、TouchDelegateはタップそのものを子の要素に渡すので、rippleエフェクトなどもしっかりと効くところが特徴です。親のFrameLayoutの範囲をタップしたときに、TouchDelegateであれば、ImageViewに設定したrippleエフェクトが発火してくれます。(エフェクトが必要ない場合は親のViewGroupに直接イベントをつけていい気がします)
TouchDelegateの実装はなかなかに面倒
親のFrameLayoutがタップしたときに、子のImageViewにイベントを移乗すると言うのは、口で言うと簡単な事のように思えますが、実際TouchDelegateを使って実装しようと思うとなかなか面倒です。
Android Developers『子ビューのタップ可能エリアを拡張する』を読めばわかりますが、Layoutで指定するのではなくコードから実装する必要があります。しかもRectを使って、自分で幅を決めたりなどする必要があるみたいです。
親のFrameLayoutの範囲を使う場合はRectをFrameLayoutの幅から取得できるので多少楽かもですが、どちらにせよコードで書く必要があります。
1つのViewのImageViewに全部TouchDelegateするとして、その要素がたくさんあった場合それだけでActivityやFragmentのonCreateが肥大化しそうですね。笑
正直、XMLで親のVIewGroupにTouchDelegateする子ViewのIDを指定するだけでよしなにやって欲しいぐらいな感じです。
なので、BindingAdapterを使ってなんとかできないものかと思ってちょっとやってみました。
TouchDelegateをLayoutから委譲先のIDで指定できるようにする
BindingAdapterを使ってLayoutから簡単に指定できるようにします。
BindingAdapterって?と言う方はこちらを。
@BindingAdapter("touchDelegateId")
fun ViewGroup.touchDelegate(id: Int) {
doOnPreDraw {
val rect = Rect().also { r ->
this.getDrawingRect(r)
}
this.touchDelegate = TouchDelegate(rect, findViewById(id))
}
}
これだけです。これをBindingAdapterに書くだけで。自身へのタッチイベントを別のViewにdelegateできるようになります。
<FrameLayout
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintBottom_toBottomOf="@id/textPostTime"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/textPostTime"
app:touchDelegateId="@{@id/imageAction}">
<ImageView
android:id="@+id/imageAction"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_arrow_drop_down" />
</FrameLayout>
親の要素であるFrameLayoutで、 app:touchDelegateId
でImageViewのidを指定しています。これだけで、FrameLayoutをタップしたときにImageViewへdelegateしてくれるようになります。
親のFrameLayoutをタップしていますが、子のImageViewのタップイベントが発火しています。これでとても押しやすくなりました!
Before | After |
---|---|
他の例として、こんな感じにも使えますね。
TwitterのリツイートやLikeボタンはだいぶアバウトにタップしても反応します。TouchDelegateを親の要素に指定してあげれば非常に押しやすくなります。TouchDelegateを使っているので、ちゃんとImageViewがタップされていることになっているのがポイントですね。
Before | After |
---|---|
実装解説
@BindingAdapter("touchDelegateId")
fun ViewGroup.touchDelegate(id: Int) {
doOnPreDraw {
val rect = Rect().also { r ->
this.getDrawingRect(r)
}
this.touchDelegate = TouchDelegate(rect, findViewById(id))
}
}
親のViewGroupに対するBindingAdapterです。XMLで指定して受け取るtouchDelegateIdは委譲先の子Viewになります。
val rect = Rect().also { r ->
this.getDrawingRect(r)
}
この部分で親ViewGroupからRectを取得しています。
this.touchDelegate = TouchDelegate(rect, findViewById(id))
TouchDelegate()の第一引数でタッチする座標Rectを渡します。先ほどViewGroupをもとにRectを生成したので、有効タップ座標は親のViewGroupになります。
findViewByIdで委譲先の子Viewを取得して、第二引数に渡しています。
それを親のViewGroup.touchDelegateに代入している感じです。
doOnPreDraw {
val rect = Rect().also { r ->
this.getDrawingRect(r)
}
this.touchDelegate = TouchDelegate(rect, findViewById(id))
}
ここで全体を囲っているdoOnPreDrawがポイントなのですが、このTouchDelegateはLayoutが生成されたあとで設定しなければなりません。Rectが取得できないからですね。そこでこのdoOnPreDrawを使うと描画されてから実行されるようになるので、うまく動くようになります。
これらをAndroidのドキュメント通りに実装しようと思うと、onCreateで親ビューを取得し、UI スレッドにRunnableをpostして。。。と書かなければいけません。大変面倒ですが、BindingAdapterを使えばXMLにIDを指定するだけで完了です!
TouchDelegateの公式ページを読んで、え、面倒じゃない?と思いましたが、これで大変便利にTouchDelegateを適用できるようになりました!