この記事は Android Advent Calendar 2019 の7日目の記事です。
はじめに
マテリアルデザインを取り入れているアプリでDark Themeをサポートする場合、アプリのThemeを Theme.AppCompat.DayNight
の派生Themeに変更したり、ColorやDrawableの -night
の代替リソースを用意したり、といった作業を行うことになりますが、それに加えて必要になるのがElevation Overlayの適用です。
本記事では、Elevation Overlayとは何かを説明し、Material Components for Android を使ってElevation OverlayをViewに適用する方法を説明します。
なお、この記事の執筆時点 (2019年12月7日) で、Material Components for Androidは 1.2.0-alpha02 が最新ですが、本記事は 1.1.0-beta02 での動作・表示をふまえて執筆しています。
Dark ThemeのElevation Overlayとは
マテリアルデザインは、特定の浮き上がり具合 (Elevation) を持つSurfaceを組み合わせて、画面を設計するデザインシステムです。SurfaceのElevationは、Surfaceが背面に投げかける影の長さや濃さによって表現されます。
*https://material.io/design/environment/elevation.html#elevation-in-material-design*
ただ、Dark ThemeにおいてはSurfaceが暗い色を持つため、影がほとんど見えず、影だけだとSurfaceの境界や上下関係がわかりづらくなってしまいます。そこで、マテリアルデザインでは、Dark Themeにおいては、Elevationに応じてSurfaceの背景色の明るさを変えることで、Surfaceの上下関係をわかるようにします。Elevationが高いほど、Surfaceを明るくします。
*https://material.io/design/color/dark-theme.html#properties*
具体的にどのように背景色の明るさを調整すればよいのか、ですが、Surfaceの表面に白色・半透明のオーバーレイが存在すると仮定して、そのオーバーレイの透明度をElevationに応じて変えることで、明るさを調整した背景色を得ます。このオーバーレイのことを Elevation Overlay と呼びます。
1. Surface 2. Elevation overlay *https://material.io/design/color/dark-theme.html#properties*
Elevationに応じたElevation Overlayの透明度は、ガイドラインで以下のように規程されています。
*https://material.io/design/color/dark-theme.html#properties*
Material Components for AndroidでのElevation Overlayの実現方法
Material Components for Android (以下 MDC) は 1.1.0-alpha05 から Elevation Overlay をサポートしています。
MDCにおいて、背景色にElevation Overlayを合成して描画する処理は MaterialShapeDrawable
で実装されています。Elevation Overlayの透明度の計算や、背景色にElevation Overlayを合成した色の計算などは ElevationOverlayProvider
に整理されており、MaterialShapeDrawable
は内部的に ElevationOverlayProvider
を使用しています。
MaterialCardView
, AppBarLayout
, TabLayout
, NavigationView
など、MDCから提供されるViewは、デフォルトで背景に MaterialShapeDrawable
を使っており、Elevation Overlayを適用した背景色を描画するようになっています。
具体的にどのような実装になっているのか、AppBarLayout
のソースコードを例としてみてみます。
public AppBarLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
// ...略
if (getBackground() instanceof ColorDrawable) {
ColorDrawable background = (ColorDrawable) getBackground();
MaterialShapeDrawable materialShapeDrawable = new MaterialShapeDrawable();
materialShapeDrawable.setFillColor(ColorStateList.valueOf(background.getColor()));
materialShapeDrawable.initializeElevationOverlay(context);
ViewCompat.setBackground(this, materialShapeDrawable);
}
コンストラクタの中で、ColorDrawable
を MaterialShapeDrawable
に置き換えています。AppBarLayout
の背景には一般的に ?attr/colorPrimary
や ?attr/colorSurface
などの単色が指定されるので、その場合は getBackground()
から返される実体は ColorDrawable
であり、この if
文の帰結部が実行されます。
また、MaterialShapeDrawable
がOverlay Elevationの透明度を正しく計算するためには、ViewのElevationと、親Viewの絶対Elevation (View階層のRootからのElevation) を知る必要があります。AppBarLayout
では次のメソッドもオーバーライドして、これらの情報を MaterialShapeDrawable
に渡しています。
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
MaterialShapeUtils.setParentAbsoluteElevation(this);
}
@RequiresApi(VERSION_CODES.LOLLIPOP)
@Override
public void setElevation(float elevation) {
super.setElevation(elevation);
MaterialShapeUtils.setElevation(this, elevation);
}
使われているUtility関数の実装は次の通りです。
public static void setElevation(@NonNull View view, float elevation) {
Drawable background = view.getBackground();
if (background instanceof MaterialShapeDrawable) {
((MaterialShapeDrawable) background).setElevation(elevation);
}
}
public static void setParentAbsoluteElevation(@NonNull View view) {
Drawable background = view.getBackground();
if (background instanceof MaterialShapeDrawable) {
setParentAbsoluteElevation(view, (MaterialShapeDrawable) background);
}
}
public static void setParentAbsoluteElevation(
@NonNull View view, @NonNull MaterialShapeDrawable materialShapeDrawable) {
if (materialShapeDrawable.isElevationOverlayEnabled()) {
materialShapeDrawable.setParentAbsoluteElevation(ViewUtils.getParentAbsoluteElevation(view));
}
}
AppBarLayout.setElevation()
ではElevationを、AppBarLayout.onAttachedToWindow()
では親Viewの絶対Elevationを MaterialShapeDrawable
に設定しています。ViewのElevationは親Viewからの相対Elevationでしかない一方、Overlay Elevationの透明度は絶対Elevationにもとづいて計算する必要があるため、MaterialShapeDrawable
は当該Viewの相対Elevationと親Viewの絶対Elevationの両方を受け取って、その和を計算に利用する作りになっています。
Material Components for Android以外のViewにElevation Overlayを適用する方法
MDCが提供するコンポーネントには自動的にElevation Overlayが適用されますが、では、それ以外のViewにElevation Overlayを適用したい場合は、どのように実装すればよいでしょうか。
ViewのElevationが固定なのであれば、Elevationが1dpときの背景色、2dpのときの背景色... とElevationに応じたColorリソースを定義しておいて、それを参照することもできるかもしれません。ただ、多くのColorリソースを定義したり、ViewのElevationと参照するColorリソースを常に整合させておく必要があるため、保守が大変そうです。
上で説明した MaterialShapeDrawable
や ElevationOverlayProvider
はMDCの公開クラスであるため、一般的には、これらのクラスを使って、Elevationにもとづいて背景色を計算・変更するのが良さそうです。Viewの背景に何を設定しているかや、Elevationが動的に変わるかどうかによっていくつかの実装の仕方が考えられますが、ここでは、背景を MaterialShapeDrawable
で置き換える方法を主に紹介します。
カスタムViewの場合
独自に実装をするカスタムViewの場合は、上の AppBarLayout
の例で見た実装方法をそのまま真似することができます。
- コンストラクタの中で
ColorDrawable
をMaterialShapeDrawable
で置き換える
if (getBackground() instanceof ColorDrawable) {
ColorDrawable background = (ColorDrawable) getBackground();
MaterialShapeDrawable materialShapeDrawable = new MaterialShapeDrawable();
materialShapeDrawable.setFillColor(ColorStateList.valueOf(background.getColor()));
materialShapeDrawable.initializeElevationOverlay(context);
ViewCompat.setBackground(this, materialShapeDrawable);
}
-
onAttachedToWindow()
をオーバーライドして、MaterialShapeUtils.setParentAbsoluteElevation(this)
を呼び出す
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
MaterialShapeUtils.setParentAbsoluteElevation(this);
}
-
setElevation(float)
をオーバーライドして、MaterialShapeUtils.setElevation(this, elevation)
を呼び出す
@RequiresApi(VERSION_CODES.LOLLIPOP)
@Override
public void setElevation(float elevation) {
super.setElevation(elevation);
MaterialShapeUtils.setElevation(this, elevation);
}
Android SDKやその他ライブラリが提供するViewの場合
Android SDKやその他ライブラリが提供するViewの場合は、実装を修正できないのですが、外部から View
のメソッドを呼び出すことで背景を MaterialShapeDrawable
に変更することができます。また、View.isAttachedToWindow
や View.OnAttachStateChangeListener
を利用することで、ViewがWindowにアタッチされたタイミングで処理を実行することも可能です。
例えば、次のような拡張関数をViewに対して定義すれば、
fun View.setElevationWithMaterialOverlay(elevation: Float) {
this.elevation = elevation
when (val originalBackground = background) {
is ColorDrawable -> {
val drawable = MaterialShapeDrawable()
drawable.fillColor = ColorStateList.valueOf(originalBackground.color)
drawable.initializeElevationOverlay(context)
drawable.elevation = elevation
background = drawable
doOnAttachedToWindow {
MaterialShapeUtils.setParentAbsoluteElevation(this)
}
}
is MaterialShapeDrawable -> {
originalBackground.elevation = elevation
}
else -> {
return
}
}
}
private fun View.doOnAttachedToWindow(action: View.() -> Unit) {
if (isAttachedToWindow) {
action()
} else {
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
removeOnAttachStateChangeListener(this)
action()
}
override fun onViewDetachedFromWindow(v: View) {
}
})
}
}
この関数を呼び出すことで、背景を MaterialShapeDrawable
に置き換えつつ、Elevationを設定することができます。
val elevation = resources.getDimension(R.dimen.elevation)
view.setElevationWithMaterialOverlay(elevation)
また、この拡張関数に @BindingAdapter
アノテーションをつけてData BindingのAdapterとすれば、
@BindingAdapter("materialElevation")
fun View.setElevationWithMaterialOverlay(elevation: Float) {
Layout XMLにおいて android:elevation
の代わりに app:materialElevation
を使えば、Elevation Overlayが適用できるようになります。
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -31,7 +31,7 @@
android:layout_gravity="center_horizontal"
android:layout_marginBottom="32dp"
android:background="?attr/colorSurface"
- android:elevation="@dimen/elevation">
+ app:materialElevation="@{@dimen/elevation}">
<TextView
android:layout_width="wrap_content"
@@ -55,7 +55,7 @@
android:layout_gravity="center_horizontal"
android:layout_marginBottom="64dp"
android:background="@drawable/bg_shape"
- android:elevation="@{viewModel.elevation}">
+ app:materialElevation="@{viewModel.elevation}">
<TextView
android:layout_width="wrap_content"
ColorDrawable以外を背景に使っている場合
背景が ColorDrawable
でない場合、MaterialShapeDrawable
で置き換えるのではなく、
- Elevation Overlayの透明度や色を
ElevationOverlayProvider
を使って計算する - 計算したElevation Overlayの効果を、
ColorFilter
として背景のDrawableに設定する
という方法でElevation Overlayを実装することができます。例えば、上で紹介した View.setElevationWithMaterialOverlay()
関数だと、when
式の else
ケースを次のように修正すれば、他の種類の背景の場合でもElevation Overlayが適用できます。
else -> {
val overlayProvider = ElevationOverlayProvider(context)
if (overlayProvider.isThemeElevationOverlayEnabled) {
doOnAttachedToWindow {
val overlayColor = overlayProvider.themeElevationOverlayColor
val absoluteElevation = this.elevation + parentAbsoluteElevation
val overlayAlpha = overlayProvider.calculateOverlayAlpha(absoluteElevation)
val overlay = ColorUtils.setAlphaComponent(overlayColor, overlayAlpha)
background.colorFilter =
PorterDuffColorFilter(overlay, PorterDuff.Mode.SRC_ATOP)
}
}
}
コードの詳細の説明は割愛しますが、ElevationOverlayProvider
を使うことで、そもそもElevation Overlayが必要かどうか (Light Themeでは不要) や、Elevationに応じたOverlayの透明度、Overlayの色などを計算することができます。
紹介したコードの完全版
Android SDKのViewにElevation Overlayを適用するコードのサンプルは、GitHubに kafumi/android-elevation-overlay-binding として公開してあります。
Data BindingのAdapterの実装は app/src/main/java/io/github/kafumi/elevationoverlaybinding/MaterialBinding.kt です。
紹介した実装方法の制限
紹介した実装方法では、親Viewの絶対Elevationを View.onAttachedToWindow()
が呼ばれたときだけ取得しており、親ViewのElevationが動的に変わるケースに対応していません。
おまけ: Elevation Overlayの色を変更する方法
MaterialShapeDrawable
や ElevationOverlayProvider
は、Themeの elevationOverlayColor
属性で定義された色をElevation Overlayの色として使うように実装されています。アプリで Theme.MaterialComponents
の派生Themeを使っている場合、elevationOverlayColor
にはデフォルトで colorOnSurface
が設定されます。
一般的にはこの設定で問題ないと思いますが、colorOnSurface
とは異なる色をElevation Overlayに使いたい場合は、elevationOverlayColor
属性の値を上書きすることで、Elevation Overlayの色を変更することができます。
<style name="Theme.App" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorOnSurface">#ECEFF1</item>
<item name="elevationOverlayColor">#FFFFFF</item>
</style>