Material Componentsを使ってDark ThemeのElevation Overlayを実装する

この記事は [Android Advent Calendar 2019](https://qiita.com/advent-calendar/2019/android) の7日目の記事です。

# はじめに

マテリアルデザインを取り入れているアプリでDark Themeをサポートする場合、アプリのThemeを `Theme.AppCompat.DayNight` の派生Themeに変更したり、ColorやDrawableの `-night` の代替リソースを用意したり、といった作業を行うことになりますが、それに加えて必要になるのがElevation Overlayの適用です。

本記事では、Elevation Overlayとは何かを説明し、[Material Components for Android](https://github.com/material-components/material-components-android) を使ってElevation OverlayをViewに適用する方法を説明します。

なお、この記事の執筆時点 (2019年12月7日) で、Material Components for Androidは [1.2.0-alpha02](https://github.com/material-components/material-components-android/releases/tag/1.2.0-alpha02) が最新ですが、本記事は [1.1.0-beta02](https://github.com/material-components/material-components-android/releases/tag/1.1.0-beta02) での動作・表示をふまえて執筆しています。

# Dark ThemeのElevation Overlayとは

マテリアルデザインは、特定の浮き上がり具合 (Elevation) を持つSurfaceを組み合わせて、画面を設計するデザインシステムです。SurfaceのElevationは、Surfaceが背面に投げかける影の長さや濃さによって表現されます。

> <img width="640" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80557/cbfe1231-7651-c36e-d650-836fc920ebcd.png" />
> *https://material.io/design/environment/elevation.html#elevation-in-material-design*

ただ、Dark ThemeにおいてはSurfaceが暗い色を持つため、影がほとんど見えず、影だけだとSurfaceの境界や上下関係がわかりづらくなってしまいます。そこで、マテリアルデザインでは、Dark Themeにおいては、Elevationに応じてSurfaceの背景色の明るさを変えることで、Surfaceの上下関係をわかるようにします。Elevationが高いほど、Surfaceを明るくします。

> <img width="640" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80557/db4985ad-f563-7104-3728-bb18f0094ba0.gif" />
> *https://material.io/design/color/dark-theme.html#properties*

具体的にどのように背景色の明るさを調整すればよいのか、ですが、Surfaceの表面に白色・半透明のオーバーレイが存在すると仮定して、そのオーバーレイの透明度をElevationに応じて変えることで、明るさを調整した背景色を得ます。このオーバーレイのことを **Elevation Overlay** と呼びます。

> <img width="640" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80557/ccaa6303-06f8-891d-aabd-90fdde2ebf4a.png" />
> 1. Surface
> 2. Elevation overlay
> *https://material.io/design/color/dark-theme.html#properties*

Elevationに応じたElevation Overlayの透明度は、ガイドラインで以下のように規程されています。

> <img width="360" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80557/31ebecc7-d569-7c27-5442-ff48713bf8c4.png" />
> *https://material.io/design/color/dark-theme.html#properties*

# Material Components for AndroidでのElevation Overlayの実現方法

Material Components for Android (以下 MDC) は [1.1.0-alpha05](https://github.com/material-components/material-components-android/releases/tag/1.1.0-alpha05) から Elevation Overlay をサポートしています。

MDCにおいて、背景色にElevation Overlayを合成して描画する処理は [`MaterialShapeDrawable`](https://developer.android.com/reference/com/google/android/material/shape/MaterialShapeDrawable.html) で実装されています。Elevation Overlayの透明度の計算や、背景色にElevation Overlayを合成した色の計算などは [`ElevationOverlayProvider`](https://developer.android.com/reference/com/google/android/material/elevation/ElevationOverlayProvider.html) に整理されており、`MaterialShapeDrawable` は内部的に `ElevationOverlayProvider` を使用しています。

[`MaterialCardView`](https://developer.android.com/reference/com/google/android/material/card/MaterialCardView.html), [`AppBarLayout`](https://developer.android.com/reference/com/google/android/material/appbar/AppBarLayout.html), [`TabLayout`](https://developer.android.com/reference/com/google/android/material/tabs/TabLayout.html), [`NavigationView`](https://developer.android.com/reference/com/google/android/material/navigation/NavigationView.html) など、MDCから提供されるViewは、デフォルトで背景に `MaterialShapeDrawable` を使っており、Elevation Overlayを適用した背景色を描画するようになっています。

具体的にどのような実装になっているのか、`AppBarLayout` のソースコードを例としてみてみます。

```java:material-components-android/lib/java/com/google/android/material/appbar/AppBarLayout.java
  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`](https://developer.android.com/reference/android/graphics/drawable/ColorDrawable.html) を `MaterialShapeDrawable` に置き換えています。`AppBarLayout` の背景には一般的に `?attr/colorPrimary` や `?attr/colorSurface` などの単色が指定されるので、その場合は `getBackground()` から返される実体は `ColorDrawable` であり、この `if` 文の帰結部が実行されます。

また、`MaterialShapeDrawable` がOverlay Elevationの透明度を正しく計算するためには、ViewのElevationと、親Viewの絶対Elevation (View階層のRootからのElevation) を知る必要があります。`AppBarLayout` では次のメソッドもオーバーライドして、これらの情報を `MaterialShapeDrawable` に渡しています。

```java
  @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関数の実装は次の通りです。

```java
  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` で置き換える

```java
    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)` を呼び出す

```java
  @Override
  protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    MaterialShapeUtils.setParentAbsoluteElevation(this);
  }
```

- `setElevation(float)` をオーバーライドして、`MaterialShapeUtils.setElevation(this, elevation)` を呼び出す

```java
  @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`](https://developer.android.com/reference/android/view/View.html#isAttachedToWindow()) や [`View.OnAttachStateChangeListener`](https://developer.android.com/reference/android/view/View.OnAttachStateChangeListener.html) を利用することで、ViewがWindowにアタッチされたタイミングで処理を実行することも可能です。

例えば、次のような拡張関数をViewに対して定義すれば、

```kotlin
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を設定することができます。

```kotlin
        val elevation = resources.getDimension(R.dimen.elevation)
        view.setElevationWithMaterialOverlay(elevation)
```

また、この拡張関数に `@BindingAdapter` アノテーションをつけてData BindingのAdapterとすれば、

```kotlin
@BindingAdapter("materialElevation")
fun View.setElevationWithMaterialOverlay(elevation: Float) {
```

Layout XMLにおいて `android:elevation` の代わりに `app:materialElevation` を使えば、Elevation Overlayが適用できるようになります。

```diff
--- 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` で置き換えるのではなく、

1. Elevation Overlayの透明度や色を `ElevationOverlayProvider` を使って計算する
2. 計算したElevation Overlayの効果を、[`ColorFilter`](https://developer.android.com/reference/android/graphics/ColorFilter.html) として背景のDrawableに設定する

という方法でElevation Overlayを実装することができます。例えば、上で紹介した `View.setElevationWithMaterialOverlay()` 関数だと、`when` 式の `else` ケースを次のように修正すれば、他の種類の背景の場合でもElevation Overlayが適用できます。

```kotlin
        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](https://github.com/kafumi/android-elevation-overlay-binding) として公開してあります。

Data BindingのAdapterの実装は [app/src/main/java/io/github/kafumi/elevationoverlaybinding/MaterialBinding.kt](https://github.com/kafumi/android-elevation-overlay-binding/blob/master/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の色を変更することができます。

```xml:res/values-night/themes.xml
  <style name="Theme.App" parent="Theme.MaterialComponents.DayNight.NoActionBar">
    <item name="colorOnSurface">#ECEFF1</item>
    <item name="elevationOverlayColor">#FFFFFF</item>
  </style>
```