Drawable Tintingと格闘したログ

 最近、就職してから初めてリリースしたアプリのリファクタをゴリゴリやっているのですが、その過程でDrawable Tintingと格闘し無限に吐きそうになりました🤪
 という訳で今回は、Drawable Tinting周りで同じ轍を踏む人が少しでも減るように、僕の苦労をログとして残しとこうという記事です。結局、満足度80%くらいのゴールで妥協しちゃってますが😧

目指したゴール

 Material Designに沿ったボタンをXMLだけで用意する。
 具体的には「丸ボタン」と「角丸ボタン」。色の条件は以下3つ。

  • 使用する色はinfowarningcautionの3色
  • 上記の色を背景とし、白いオーバーレイがかかる
  • 白背景に対し、黒or上記の色のオーバーレイがかかる

使う3色

<!-- values/colors.xml -->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
    <color name="info">#8BC34A</color>
    <color name="warning">#FFC107</color>
    <color name="caution">#F44336</color>
</resources>

その他条件は以下の通り

  • Android4系以上(minSdkVersion 14)
  • 外部ライブラリは使用しない
  • tintModeはデフォルト値固定(PorterDuff.Mode.SRC_IN)

最初に抑えておく注意点

Ripple Effectが使えるのはAPI21以降

 RippleDrawableはAPI21未満では使えません。
 外部のライブラリを導入すれば、一応はAPI21未満でもRipple Effectを有効にすることが可能です。ですが、Githubで1番スターの多いライブラリ(traex/RippleEffect)ですら2016年2月で開発止まってたりするので、古いAPI LevelはColorStateListで対応する方法が最も費用対効果高い気がします。

各種tint属性は条件によって使えない場合がある

 tintbackgroundTintforegroundTint属性は各ViewクラスとAPI Levelによって使えない場合があります。また、foreground属性にもちょっとした罠が…。

tint属性

< API21 API21 <= API23 <=
ImageView
AppCompatImageView

backgroundTint属性

< API21 API21 <= API23 <=
ImageView
TextView
AppCompatImageView
AppCompatTextView
FrameLayout

foreground属性

< API21 API21 <= API23 <=
ImageView
TextView
AppCompatImageView
AppCompatTextView
FrameLayout

※ViewのsetForegroundメソッドはドキュメントだとAPI1から利用可となっているが、実際はAPI23で追加されている(API22→23のdiff)
※API23未満のViewに対してforegroundを指定しても、特にエラーは出ない。つらい(´・ω・`)

foregroundTint属性

< API21 API21 <= API23 <=
ImageView
TextView
AppCompatImageView
AppCompatTextView
FrameLayout

Drawable Tinting・Ripple Effectの動作がAPI Levelによって異なる

 RippleDrawableに対しtintbackgroundTint属性を指定した時、API21では何故かRipple Effectが効かなくなります。API22以降では問題なく動作するのでバグっぽい?
 また、API23未満とAPI23以降でRippleDrawableの動作が若干異なります。
 ここもめちゃくちゃ闇が深いので後日別記事にまとめます(´・ω・`)

試行錯誤ログ

その1: ボタン毎にリソースファイルを作成

ボタン毎に1つ1つリソースファイルを作ってしまう方法。原始的だけど1番簡単かつ確実。

<!-- drawable/circle_info.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false">
        <shape android:shape="oval">
            <solid android:color="@color/info_disabled"/>
        </shape>
    </item>
    <item android:state_hovered="true">
        <shape android:shape="oval">
            <solid android:color="@color/info_hovered"/>
        </shape>
    </item>
    <item android:state_pressed="true">
        <shape android:shape="oval">
            <solid android:color="@color/info_pressed"/>
        </shape>
    </item>
    <item android:state_activated="true">
        <shape android:shape="oval">
            <solid android:color="@color/info_activated"/>
        </shape>
    </item>
    <item android:state_focused="true">
        <shape android:shape="oval">
            <solid android:color="@color/info_focused"/>
        </shape>
    </item>
    <item android:state_selected="true">
        <shape android:shape="oval">
            <solid android:color="@color/info_selected"/>
        </shape>
    </item>
    <item>
        <shape android:shape="oval">
            <solid android:color="@color/info"/>
        </shape>
    </item>
</selector>
<!-- drawable/background_circle_info.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/circle_info"/>
</selector>
<!-- drawable-v21/background_circle_info.xml -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="#52FFFFFF">

    <item>
        <selector>
            <item android:state_pressed="true">
                <shape android:shape="oval">
                    <solid android:color="@color/info"/>
                </shape>
            </item>
            <item android:state_hovered="true">
                <shape android:shape="oval">
                    <solid android:color="@color/info"/>
                </shape>
            </item>
            <item android:state_focused="true">
                <shape android:shape="oval">
                    <solid android:color="@color/info"/>
                </shape>
            </item>
            <item android:drawable="@drawable/circle_info"/>
        </selector>
    </item>
</ripple>
<android.support.v7.widget.AppCompatImageView
    android:id="@+id/circle"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_margin="8dp"
    android:padding="8dp"
    android:src="@drawable/ic_android_24dp"
    android:background="@drawable/background_circle_info"
    app:tint="@android:color/white" />

メリット

  • 考えることが少ない
  • 必要な状態だけに限定したリソースファイルにすれば、メンテコストをいくらか軽減できる

デメリット

  • リソースファイルが色の数 x shapeの数だけできる
  • リソースファイル編集時にAndroid Studioが重くなる
  • メンテがきつい

 スタート地点の状態です。難しいことを考える必要が少ない分、完全な力技なのでメンテが鬼のようにキツくなります。また、行数の多いリソースファイルをいじっているとAndroid Studioがすぐに重くなります。
 そして、リソースファイルの数は4種類のボタンx3色で計44個。これをいかに減らせるかがその2以降のポイントです。

その2: ColorStateListを色毎に定義

ColorStateListを色毎に定義し、それをbackgroundTint属性に指定します。

<!-- color/info_stateful.xml -->
    <selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false" android:color="@color/info_disabled"/>
    <item android:state_hovered="true" android:color="@color/info_hovered"/>
    <item android:state_pressed="true" android:color="@color/info_pressed"/>
    <item android:state_activated="true" android:color="@color/info_activated"/>
    <item android:state_focused="true" android:color="@color/info_focused"/>
    <item android:state_selected="true" android:color="@color/info_selected"/>
    <item android:color="@color/info"/>
</selector>
<!-- color-v21/info_stateful.xml -->
    <selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false" android:color="@color/info_disabled"/>
    <item android:state_activated="true" android:color="@color/info_activated"/>
    <item android:state_selected="true" android:color="@color/info_selected"/>
    <item android:color="@color/info"/>
</selector>
<!-- drawable/circle.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@android:color/black"/>
</shape>
<!-- drawable-v21/circle.xml -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/white_ripple">

    <item android:id="@android:id/mask">
        <shape android:shape="oval">
            <solid android:color="@android:color/black"/>
        </shape>
    </item>

    <item>
        <shape android:shape="oval">
            <solid android:color="@android:color/black"/>
        </shape>
    </item>
</ripple>
<android.support.v7.widget.AppCompatImageView
    android:id="@+id/circle"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_margin="8dp"
    android:padding="8dp"
    android:src="@drawable/ic_android_24dp"
    android:background="@drawable/circle"
    app:tint="@android:color/white"
    app:backgroundTint="@color/info_stateful"/>

メリット

  • 色の状態がカラーリソースにまとまり、メンテしやすくなる

デメリット

  • ColorStateListに@android:color/transparentが含まれるとRipple Effectが効かなくなる

 Drawableがボタン4種x2で8つ、カラーリソースを合わせても合計24個に収まります。1番の問題は、ColorStateListに@android:color/transparentを指定できないことでしょう。
 RippleDrawableにtint属性を指定した時、どうやらandroid:id="@android:id/mask"の色もtint属性に指定したモノに上書きされてしまい、@android:color/transparentが含まれるColorStateListを指定するとRipple Effectが効かなくなります。
 また、前述の通りAPI21では問答無用でRipple Effect効きません。

その3: オーバーレイの色をColorStateListに定義、メインの色はbackgroundTintで指定

 ボタンに重ねるオーバーレイの色をColorStateListに定義してよしなに切り替える方法です。

<!-- color/opacity_white.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false" android:color="@color/white_opacity_disabled"/>
    <item android:state_hovered="true" android:color="@color/white_opacity_hovered"/>
    <item android:state_pressed="true" android:color="@color/white_opacity_pressed"/>
    <item android:state_activated="true" android:color="@color/white_opacity_activated"/>
    <item android:state_focused="true" android:color="@color/white_opacity_focused"/>
    <item android:state_selected="true" android:color="@color/white_opacity_selected"/>
    <item android:color="@android:color/white"/>
</selector>

<!-- drawable-v21/circle.xml -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/white_overlay_pressed">

    <item android:id="@android:id/mask">
        <shape android:shape="oval">
            <solid android:color="@android:color/white"/>
        </shape>
    </item>

    <item>
        <selector>
            <item android:state_pressed="true">
                <shape android:shape="oval">
                    <solid android:color="@android:color/white"/>
                </shape>
            </item>
            <item android:state_focused="true">
                <shape android:shape="oval">
                    <solid android:color="@android:color/white"/>
                </shape>
            </item>
            <item android:state_hovered="true">
                <shape android:shape="oval">
                    <solid android:color="@android:color/white"/>
                </shape>
            </item>
            <item>
                <shape android:shape="oval">
                    <solid android:color="@color/opacity_white"/>
                </shape>
            </item>
        </selector>
    </item>
</ripple>
<android.support.v7.widget.AppCompatImageView
    android:id="@+id/circle"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_margin="8dp"
    android:padding="8dp"
    android:src="@drawable/ic_android_24dp"
    android:background="@drawable/circle"
    app:tint="@android:color/white"
    app:backgroundTint="@color/info"/>

メリット

  • 色付きボタン用のカラーリソースが1つで済む

デメリット

  • 白背景時のオーバーレイを正しく実装できない

 色付きボタンはイイ感じに実装できるのですが、白背景時のオーバーレイで躓きます。その2と同じ、「tintに指定したColorStateListでandroid:id="@android:id/mask"の色が上書きされてしまう」問題により、state_pressedがtrueの時にRipple Effectが消えてしまいます。

その4: ImageViewのforegroundにオーバーレイを指定する

 その3までは、ボタンの色そのものをtintを使って上書きしてしまおうという手法。ここからは、オーバーレイ用のDrawable・ColorStateListをボタンの上に重ねる方向性で実装してみます。

<!-- color/overlay_white.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false" android:color="@color/white_overlay_disabled"/>
    <item android:state_hovered="true" android:color="@color/white_overlay_hovered"/>
    <item android:state_pressed="true" android:color="@color/white_overlay_pressed"/>
    <item android:state_activated="true" android:color="@color/white_overlay_activated"/>
    <item android:state_focused="true" android:color="@color/white_overlay_focused"/>
    <item android:state_selected="true" android:color="@color/white_overlay_selected"/>
    <item android:color="@android:color/transparent"/>
</selector>
<!-- drawable/circle.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@android:color/black"/>
</shape>
<!-- drawable/circle_overlay_white.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false">
        <shape android:shape="oval">
            <solid android:color="@color/white_overlay_disabled"/>
        </shape>
    </item>
    <item android:state_hovered="true">
        <shape android:shape="oval">
            <solid android:color="@color/white_overlay_hovered"/>
        </shape>
    </item>
    <item android:state_pressed="true">
        <shape android:shape="oval">
            <solid android:color="@color/white_overlay_pressed"/>
        </shape>
    </item>
    <item android:state_activated="true">
        <shape android:shape="oval">
            <solid android:color="@color/white_overlay_activated"/>
        </shape>
    </item>
    <item android:state_focused="true">
        <shape android:shape="oval">
            <solid android:color="@color/white_overlay_focused"/>
        </shape>
    </item>
    <item android:state_selected="true">
        <shape android:shape="oval">
            <solid android:color="@color/white_overlay_selected"/>
        </shape>
    </item>
    <item>
        <shape android:shape="oval">
            <solid android:color="@android:color/transparent"/>
        </shape>
    </item>
</selector>
<!-- drawable-v21/circle_overlay_white.xml -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/white_overlay_pressed">

    <item android:id="@android:id/mask">
        <shape android:shape="oval">
            <solid android:color="@android:color/white"/>
        </shape>
    </item>

    <item>
        <selector>
            <item android:state_pressed="true">
                <shape android:shape="oval">
                    <solid android:color="@android:color/transparent"/>
                </shape>
            </item>
            <item android:state_focused="true">
                <shape android:shape="oval">
                    <solid android:color="@android:color/transparent"/>
                </shape>
            </item>
            <item android:state_hovered="true">
                <shape android:shape="oval">
                    <solid android:color="@android:color/transparent"/>
                </shape>
            </item>
            <item>
                <shape android:shape="oval">
                    <solid android:color="@color/overlay_white"/>
                </shape>
            </item>
        </selector>
    </item>
</ripple>
<android.support.v7.widget.AppCompatImageView
    android:id="@+id/circle"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_margin="8dp"
    android:padding="8dp"
    android:src="@drawable/ic_android_24dp"
    android:background="@drawable/circle"
    android:foreground="@drawable/circle_overlay_white"
    app:tint="@android:color/white"
    app:backgroundTint="@color/info"/>

 リソースファイルの総数は19個。非常にシンプルで動作にも問題のない、限りなく最適解に近い方法です。foreground属性がAPI23以降のみ対応である点を除けば
 流石にLollipopはまだサポート切れないと思うので、もうひと踏ん張り。

その5: FrameLayoutのforegroundとbackgroundを使う

 FrameLayoutであれば、API1からforeground属性が使えます。これを利用しない手はない!

(ColorStateListとDrawableはその4と同じモノを流用)

<FrameLayout
    android:id="@+id/circleInfo"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_margin="8dp"
    android:foreground="@drawable/circle_overlay_white"/>
    <android.support.v7.widget.AppCompatImageView
        android:id="@+id/circle"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_margin="8dp"
        android:padding="8dp"
        android:duplicateParentState="true"
        android:src="@drawable/ic_android_24dp"
        android:background="@drawable/circle"
        app:tint="@android:color/white"
        app:backgroundTint="@color/info"/>
</FrameLayout>

 FrameLayoutが入ってくるものの、API14以上かつxmlのみでの対応という条件を満たしつつボタンのオーバーレイをほぼ要求通り実装できます。AppCompatImageViewにduplicateParentState="true"を指定しておけば、FrameLayoutの状態変化がそのまま子要素にも伝播するため、AppCompatImageViewのColorStateList・StateListDrawableも問題なく動作させることができます。

 いやぁ長かった…ホントに疲れた…(´Д`)

残る問題点

 その5の方法でほぼ欲しいモノは得られるのですが、問題点がまだ1つ。API21以降とAPI23以降でRipple Effectの動作が異なるために、focusedhovered時のオーバーレイがAPI21と22で正しく働きません。

 まぁ…、Androidアプリだったらhoveredに拘るよりRipple Effectちゃんと動作するかどうかのが大事だし、ここは妥協しても良い気がする…😇

感想

 とてもつらかった
 ただ、今まで何となくforeground="?android:attr/selectableItemBackground"とかを闇雲に指定して、その度に四苦八苦していた状態から少し先に進めたかな…?という気はします。今更感はありますが、Meterial Designへの実装ベースでの理解は確実に深まりました。
 今後は少しDrawable周りのJavaも読んで、更に深いところで理解できるようにしたい気持ちがあります。まぁしばらくはDrawableに対するアレルギー抜けなさそうなので、落ち着き次第やりましょうね😌

参考リンク

Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です