32
21

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.

Android Advent Calendar 2019

Day 7

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

Last updated at Posted at 2019-12-07

この記事は 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 のソースコードを例としてみてみます。

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);
    }

コンストラクタの中で、ColorDrawableMaterialShapeDrawable に置き換えています。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リソースを常に整合させておく必要があるため、保守が大変そうです。

上で説明した MaterialShapeDrawableElevationOverlayProvider はMDCの公開クラスであるため、一般的には、これらのクラスを使って、Elevationにもとづいて背景色を計算・変更するのが良さそうです。Viewの背景に何を設定しているかや、Elevationが動的に変わるかどうかによっていくつかの実装の仕方が考えられますが、ここでは、背景を MaterialShapeDrawable で置き換える方法を主に紹介します。

カスタムViewの場合

独自に実装をするカスタムViewの場合は、上の AppBarLayout の例で見た実装方法をそのまま真似することができます。

  • コンストラクタの中で ColorDrawableMaterialShapeDrawable で置き換える
    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.isAttachedToWindowView.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 で置き換えるのではなく、

  1. Elevation Overlayの透明度や色を ElevationOverlayProvider を使って計算する
  2. 計算した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の色を変更する方法

MaterialShapeDrawableElevationOverlayProvider は、Themeの elevationOverlayColor 属性で定義された色をElevation Overlayの色として使うように実装されています。アプリで Theme.MaterialComponents の派生Themeを使っている場合、elevationOverlayColor にはデフォルトで colorOnSurface が設定されます。

一般的にはこの設定で問題ないと思いますが、colorOnSurface とは異なる色をElevation Overlayに使いたい場合は、elevationOverlayColor 属性の値を上書きすることで、Elevation Overlayの色を変更することができます。

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>
32
21
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
32
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?