4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

子ビューのタップ可能エリアを拡張するTouchDelegateをBindingAdapterで簡単に使う

Posted at

最近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を適用できるようになりました!

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?