Android ビューの基本的な色のハーモナイゼーション

1. 始める前に

この Codelab では、カスタムカラーをダイナミック テーマで生成されたカラーと調和させる方法について学習します。

前提条件

デベロッパーは次のことを理解している必要があります。

  • Android の基本的なテーマ設定のコンセプト
  • Android ウィジェット ビューとそのプロパティの操作

学習内容

  • 複数の方法を使用して、アプリで色の調和を使用する方法
  • 調和の仕組みと色の変化

必要なもの

  • Android がインストールされたパソコン(一緒に操作する場合)。

2. アプリの概要

Voyaĝi は、ダイナミック テーマをすでに使用している交通機関アプリです。多くの交通機関システムでは、電車、バス、路面電車の重要な指標として色が使用されています。これらの色は、利用可能なダイナミック プライマリ カラー、セカンダリ カラー、ターシャリ カラーに置き換えることはできません。ここでは、色付きの交通機関カードの RecyclerView に焦点を当てて作業します。

62ff4b2fb6c9e14a.png

3. テーマの生成

Material3 テーマを作成する際は、まずツール Material Theme Builder を使用することをおすすめします。[カスタム] タブで、テーマに色を追加できるようになりました。右側に、それらの色のカラーロールと色調パレットが表示されます。

[拡張色] セクションで、色を削除または名前変更できます。

20cc2cf72efef213.png

[エクスポート] メニューには、エクスポート オプションがいくつか表示されます。この記事の執筆時点では、Material Theme Builder の調和設定の特別な処理は Android Views でのみ使用できます。

6c962ad528c09b4.png

新しいエクスポート値について

調和させるかどうかに関係なく、テーマでこれらの色とその関連するカラーロールを使用できるように、エクスポートされたダウンロードには、各カスタムカラーのカラーロール名を含む attrs.xml ファイルが含まれるようになりました。

<resources>
   <attr name="colorCustom1" format="color" />
   <attr name="colorOnCustom1" format="color" />
   <attr name="colorCustom1Container" format="color" />
   <attr name="colorOnCustom1Container" format="color" />
   <attr name="harmonizeCustom1" format="boolean" />

   <attr name="colorCustom2" format="color" />
   <attr name="colorOnCustom2" format="color" />
   <attr name="colorCustom2Container" format="color" />
   <attr name="colorOnCustom2Container" format="color" />
   <attr name="harmonizeCustom2" format="boolean" />
</resources>

themes.xml では、各カスタムカラーの 4 つのカラーロール(color<name>, colorOn<name>, color<name>Container, and colorOn<nameContainer>)が生成されています。harmonize<name> プロパティは、デベロッパーが Material Theme Builder でオプションを選択したかどうかを反映します。コアテーマの色は変更されません。

<resources>
   <style name="AppTheme" parent="Theme.Material3.Light.NoActionBar">
       <!--- Normal theme attributes ... -->

       <item name="colorCustom1">#006876</item>
       <item name="colorOnCustom1">#ffffff</item>
       <item name="colorCustom1Container">#97f0ff</item>
       <item name="colorOnCustom1Container">#001f24</item>
       <item name="harmonizeCustom1">false</item>

       <item name="colorCustom2">#016e00</item>
       <item name="colorOnCustom2">#ffffff</item>
       <item name="colorCustom2Container">#78ff57</item>
       <item name="colorOnCustom2Container">#002200</item>
       <item name="harmonizeCustom2">false</item>
   </style>
</resources>

colors.xml ファイルでは、上記の色ロールの生成に使用されるシードカラーが、色のパレットがシフトするかどうかのブール値とともに指定されます。

<resources>
   <!-- other colors used in theme -->

   <color name="custom1">#1AC9E0</color>
   <color name="custom2">#32D312</color>
</resources>

4. カスタムカラーの確認

Material Theme Builder のサイドパネルを拡大すると、カスタムカラーを追加すると、ライト パレットとダーク パレットに 4 つのキーカラーロールが表示されることがわかります。

c6ee942b2b93cd92.png

Android Views では、これらの色がエクスポートされますが、内部的には ColorRoles オブジェクトのインスタンスで表されます。

ColorRoles クラスには、accentonAccentaccentContaineronAccentContainer の 4 つのプロパティがあります。これらのプロパティは、4 つの 16 進数の色の整数表現です。

public final class ColorRoles {

  private final int accent;
  private final int onAccent;
  private final int accentContainer;
  private final int onAccentContainer;

  // truncated code

}

特定のシードカラーを指定すると、実行時に 4 つのカラーロールのセットを作成できる getColorRoles という MaterialColors クラスの getColorRoles を使用して、任意の色の 4 つのキーカラーロールを取得できます。

public static ColorRoles getColorRoles(
    @NonNull Context context,
    @ColorInt int color
) { /* implementation */ }

同様に、出力値は実際のカラー値であり、それらへのポインタではありません。**

5. 色の調和とは

Material の新しいカラーシステムはアルゴリズムで設計されており、指定されたシードカラーからプライマリ カラー、セカンダリ カラー、ターシャリ カラー、ニュートラル カラーを生成します。内部パートナーと外部パートナーに話を聞いたところ、多くの懸念事項として、一部の色を制御しながらダイナミック カラーを採用する方法が挙げられました。

これらの色は、アプリ内で特定の意味やコンテキストを持つことが多く、ランダムな色に置き換えると失われてしまいます。また、そのままにしておくと、これらの色が視覚的に不調和に見えたり、場違いに見えたりする可能性があります。

Material You の色は、色相、彩度、トーンで表されます。色の色相は、ある色範囲のメンバーとして認識されるか、別の色範囲のメンバーとして認識されるかに関連します。トーンは明るさや暗さを表し、彩度は色の強さを表します。色相の認識は、文化や言語の要因によって影響を受ける可能性があります。たとえば、古代文化では青を表す言葉がなく、緑と同じファミリーとして認識されていたことがよく知られています。

57c46d9974c52e4a.png特定の色相は、色相スペクトルのどこに位置するかによって、暖色または寒色と見なされます。赤、オレンジ、黄色の色相にシフトすると、一般的に暖色になり、青、緑、紫の色相にシフトすると、寒色になります。暖色または寒色の中でも、暖色と寒色のトーンがあります。下の図では、「暖色」の黄色はオレンジがかった色ですが、「寒色」の黄色は緑の影響を受けています。 597c6428ff6b9669.png

色の調和アルゴリズムは、シフトされていない色と調和させる色の色相を調べて、調和が取れていて、元の色の品質を変更しない色相を見つけます。最初の図では、調和の取れていない緑、黄、オレンジの色相がスペクトル上にプロットされています。次の図では、緑とオレンジが黄色の色相と調和しています。新しい緑は暖色で、新しいオレンジは寒色です。

オレンジと緑の色相はシフトしていますが、オレンジと緑として認識できます。

766516c321348a7c.png

設計上の決定、調査、考慮事項について詳しくは、同僚の Ayan Daniels と Andrew Lu が執筆した ブログ投稿をご覧ください。このセクションよりも詳しく説明されています。

6. 色を手動で調和させる

単一のトーンを調和させるには、MaterialColorsharmonizeharmonizeWithPrimary の 2 つの関数があります。

harmonizeWithPrimary は、現在のテーマにアクセスし、そこからプライマリ カラーを取得する手段として Context を使用します。

@ColorInt
public static int harmonizeWithPrimary(@NonNull Context context, @ColorInt int colorToHarmonize) {
    return harmonize(
        colorToHarmonize,
        getColor(context, R.attr.colorPrimary, MaterialColors.class.getCanonicalName()));
  }


@ColorInt
  public static int harmonize(@ColorInt int colorToHarmonize, @ColorInt int colorToHarmonizeWith) {
    return Blend.harmonize(colorToHarmonize, colorToHarmonizeWith);
  }

4 つのトーンのセットを取得するには、もう少し作業が必要です。

ソースカラーがすでに存在する場合は、次の操作を行う必要があります。

  1. 調和させるかどうかを判断する。
  2. ダークモードかどうかを判断する。
  3. 調和された ColorRoles オブジェクトまたは調和されていない ColorRoles オブジェクトを返す。

調和させるかどうかを判断する

Material Theme Builder からエクスポートされたテーマには、harmonize<Color> という命名規則を使用してブール値属性が含まれています。その値にアクセスするための便利な関数を以下に示します。

見つかった場合はその値を返します。それ以外の場合は、色を調和させないことを決定します。

// Looks for associated harmonization attribute based on the color id
// custom1 ===> harmonizeCustom1
fun shouldHarmonize(context: Context, colorId: Int): Boolean {
   val root = context.resources.getResourceEntryName(colorId)
   val harmonizedId = "harmonize" + root.replaceFirstChar { it.uppercaseChar() }
   
   val identifier = context.resources.getIdentifier(
           harmonizedId, "bool", context.packageName)
   
   return if (identifier != 0) context.resources.getBoolean(identifier) else false
}

調和された ColorRoles オブジェクトを作成する

retrieveHarmonizedColorRoles は、前述のすべての手順を結合する別の便利な関数です。名前付きリソースのカラー値を取得し、ブール値属性を解決して調和を判断し、元の色またはブレンドされた色(ライトスキームまたはダークスキーム)から派生した ColorRoles オブジェクトを返します。

fun retrieveHarmonizedColorRoles(
   view: View,
   customId: Int,
   isLight: Boolean
): ColorRoles {
   val context = view.context
   val custom = context.getColor(customId);
  
   val shouldHarmonize = shouldHarmonize(context, customId)
   if (shouldHarmonize) {
       val blended = MaterialColors.harmonizeWithPrimary(context, custom)
       return MaterialColors.getColorRoles(blended, isLight)
   } else return MaterialColors.getColorRoles(custom, isLight)
}

7. 交通機関カードの生成

前述のように、RecyclerView とアダプターを使用して、交通機関カードのコレクションを生成して色付けします。

e4555089b065b5a7.png

交通機関データの保存

交通機関のカードのテキストデータと色情報を保存するために、名前、目的地、カラーリソース ID を保存するデータクラスを使用します。

data class TransitInfo(val name: String, val destination: String, val colorId: Int)

/*  truncated code */

val transitItems = listOf(
   TransitInfo("53", "Irvine", R.color.custom1),
   TransitInfo("153", "Brea", R.color.custom1),
   TransitInfo("Orange County Line", "Oceanside", R.color.custom2),
   TransitInfo("Pacific Surfliner", "San Diego", R.color.custom2)
)

この色を使用して、必要なトーンをリアルタイムで生成します。

次の onBindViewHolder 関数を使用して、実行時に調和させることができます。

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
   val transitInfo = list.get(position)
   val color = transitInfo.colorId
   if (!colorRolesMap.containsKey(color)) {

       val roles = retrieveHarmonizedColorRoles(
           holder.itemView, color,
           !isNightMode(holder.itemView.context)
       )
       colorRolesMap.put(color, roles)
   }

   val card = holder.card
   holder.transitName.text = transitInfo.name
   holder.transitDestination.text = transitInfo.destination

   val colorRoles = colorRolesMap.get(color)
   if (colorRoles != null) {
       holder.card.setCardBackgroundColor(colorRoles.accentContainer)
       holder.transitName.setTextColor(colorRoles.onAccentContainer)
       holder.transitDestination.setTextColor(colorRoles.onAccentContainer)
   }
}

8. 色を自動的に調和させる

調和を手動で処理する代わりに、自動で処理することもできます。 HarmonizedColorOptions は、これまで手動で行ってきた作業の多くを指定できるビルダー クラスです。

現在のコンテキストを取得して現在のダイナミック スキームにアクセスしたら、調和させるベースカラーを指定し、その HarmonizedColorOptions オブジェクトと DynamicColors が有効になっているコンテキストに基づいて新しいコンテキストを作成する必要があります。

色を調和させたくない場合は、harmonizedOptions に含めないでください。

val newContext = DynamicColors.wrapContextIfAvailable(requireContext())


val harmonizedOptions = HarmonizedColorsOptions.Builder()
 .setColorResourceIds(intArrayOf(R.color.custom1, R.color.custom2))
 .build();

harmonizedContext =
 HarmonizedColors.wrapContextIfAvailable(dynamicColorsContext, harmonizedOptions)

調和されたベースカラーがすでに処理されている場合は、onBindViewHolder を更新して MaterialColors.getColorRoles を呼び出し、返されたロールをライトにするかダークにするかを指定できます。

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
   /*...*/
   val color = transitInfo.colorId
   if (!colorRolesMap.containsKey(color)) {

       val roles = MaterialColors.getColorRoles(context.getColor(color), !isNightMode(context))

       )
       colorRolesMap.put(color, roles)
   }

   val card = holder.card
   holder.transitName.text = transitInfo.name
   holder.transitDestination.text = transitInfo.destination

   val colorRoles = colorRolesMap.get(color)
   if (colorRoles != null) {
       holder.card.setCardBackgroundColor(colorRoles.accentContainer)
       holder.transitName.setTextColor(colorRoles.onAccentContainer)
       holder.transitDestination.setTextColor(colorRoles.onAccentContainer)
   }
}

9. テーマ属性を自動的に調和させる

これまで説明した方法は、個々の色からカラーロールを取得することに依存しています。実際のトーンが生成されていることを示すには最適ですが、既存のほとんどのアプリでは現実的ではありません。色を直接派生させるのではなく、既存のテーマ属性を使用する可能性が高くなります。

この Codelab の前半で、テーマ属性のエクスポートについて説明しました。

<resources>
   <style name="AppTheme" parent="Theme.Material3.Light.NoActionBar">
       <!--- Normal theme attributes ... -->

       <item name="colorCustom1">#006876</item>
       <item name="colorOnCustom1">#ffffff</item>
       <item name="colorCustom1Container">#97f0ff</item>
       <item name="colorOnCustom1Container">#001f24</item>
       <item name="harmonizeCustom1">false</item>

       <item name="colorCustom2">#016e00</item>
       <item name="colorOnCustom2">#ffffff</item>
       <item name="colorCustom2Container">#78ff57</item>
       <item name="colorOnCustom2Container">#002200</item>
       <item name="harmonizeCustom2">false</item>
   </style>
</resources>

最初の自動メソッドと同様に、HarmonizedColorOptions に値を指定し、HarmonizedColors を使用して、調和された色を含む Context を取得できます。2 つの方法には 1 つ重要な違いがあります。調和させるフィールドを含むテーマ オーバーレイも指定する必要があります。

val dynamicColorsContext = DynamicColors.wrapContextIfAvailable(requireContext())

// Harmonizing individual attributes
val harmonizedColorAttributes = HarmonizedColorAttributes.create(
 intArrayOf(
   R.attr.colorCustom1,
   R.attr.colorOnCustom1,
   R.attr.colorCustom1Container,
   R.attr.colorOnCustom1Container,
   R.attr.colorCustom2,
   R.attr.colorOnCustom2,
   R.attr.colorCustom2Container,
   R.attr.colorOnCustom2Container
 ), R.style.AppTheme_Overlay
)
val harmonizedOptions =
 HarmonizedColorsOptions.Builder().setColorAttributes(harmonizedColorAttributes).build()

val harmonizedContext =
 HarmonizedColors.wrapContextIfAvailable(dynamicColorsContext, harmonizedOptions)

アダプターは調和されたコンテキストを使用します。テーマ オーバーレイの値は、調和されていない ライト バリアントまたはダーク バリアントを参照する必要があります。

<style name="AppTheme.Overlay" parent="AppTheme">
   <item name="colorCustom1">@color/harmonized_colorCustom1</item>
   <item name="colorOnCustom1">@color/harmonized_colorOnCustom1</item>
   <item name="colorCustom1Container">@color/harmonized_colorCustom1Container</item>
   <item name="colorOnCustom1Container">@color/harmonized_colorOnCustom1Container</item>

   <item name="colorCustom2">@color/harmonized_colorCustom2</item>
   <item name="colorOnCustom2">@color/harmonized_colorOnCustom2</item>
   <item name="colorCustom2Container">@color/harmonized_colorCustom2Container</item>
   <item name="colorOnCustom2Container">@color/harmonized_colorOnCustom2Container</item>
</style>

XML レイアウト ファイル内で、これらの調和された属性を通常どおり使用できます。

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   style="?attr/materialCardViewFilledStyle"
   android:id="@+id/card"
   android:layout_width="80dp"
   android:layout_height="100dp"
   android:layout_marginStart="8dp"
   app:cardBackgroundColor="?attr/colorCustom1Container"
   >

   <androidx.constraintlayout.widget.ConstraintLayout
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_margin="8dp">

       <TextView
           android:id="@+id/transitName"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:textSize="28sp"
           android:textStyle="bold"
           android:textColor="?attr/colorOnCustom1Container"
           app:layout_constraintTop_toTopOf="parent" />

       <TextView
           android:id="@+id/transitDestination"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:layout_marginBottom="4dp"
           android:textColor="?attr/colorOnCustom1Container"
           app:layout_constraintBottom_toBottomOf="parent" />
   </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

10. ソースコード

package com.example.voyagi.harmonization.ui.dashboard

import android.content.Context
import android.content.res.Configuration
import android.graphics.Typeface
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.voyagi.harmonization.R
import com.example.voyagi.harmonization.databinding.FragmentDashboardBinding
import com.example.voyagi.harmonization.ui.home.TransitCardAdapter
import com.example.voyagi.harmonization.ui.home.TransitInfo
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.ColorRoles
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.HarmonizedColorAttributes
import com.google.android.material.color.HarmonizedColors
import com.google.android.material.color.HarmonizedColorsOptions
import com.google.android.material.color.MaterialColors


class DashboardFragment : Fragment() {

 enum class TransitMode { BUS, TRAIN }
 data class TransitInfo2(val name: String, val destination: String, val mode: TransitMode)

 private lateinit var dashboardViewModel: DashboardViewModel
 private var _binding: FragmentDashboardBinding? = null

 // This property is only valid between onCreateView and
 // onDestroyView.
 private val binding get() = _binding!!

 override fun onCreateView(
   inflater: LayoutInflater,
   container: ViewGroup?,
   savedInstanceState: Bundle?
 ): View? {
   dashboardViewModel =
     ViewModelProvider(this).get(DashboardViewModel::class.java)

   _binding = FragmentDashboardBinding.inflate(inflater, container, false)
   val root: View = binding.root


   val recyclerView = binding.recyclerView

   val transitItems = listOf(
     TransitInfo2("53", "Irvine", TransitMode.BUS),
     TransitInfo2("153", "Brea", TransitMode.BUS),
     TransitInfo2("Orange County Line", "Oceanside", TransitMode.TRAIN),
     TransitInfo2("Pacific Surfliner", "San Diego", TransitMode.TRAIN)
   )
  
   val dynamicColorsContext = DynamicColors.wrapContextIfAvailable(requireContext())

   // Harmonizing individual attributes
   val harmonizedColorAttributes = HarmonizedColorAttributes.create(
     intArrayOf(
       R.attr.colorCustom1,
       R.attr.colorOnCustom1,
       R.attr.colorCustom1Container,
       R.attr.colorOnCustom1Container,
       R.attr.colorCustom2,
       R.attr.colorOnCustom2,
       R.attr.colorCustom2Container,
       R.attr.colorOnCustom2Container
     ), R.style.AppTheme_Overlay
   )
   val harmonizedOptions =
     HarmonizedColorsOptions.Builder().setColorAttributes(harmonizedColorAttributes).build()

   val harmonizedContext =
     HarmonizedColors.wrapContextIfAvailable(dynamicColorsContext, harmonizedOptions)


   val adapter = TransitCardAdapterAttr(transitItems, harmonizedContext)
   recyclerView.adapter = adapter
   recyclerView.layoutManager =
     LinearLayoutManager(harmonizedContext, RecyclerView.HORIZONTAL, false)

   return root
 }

 override fun onDestroyView() {
   super.onDestroyView()
   _binding = null
 }
}

class TransitCardAdapterAttr(val list: List<DashboardFragment.TransitInfo2>, context: Context) :
 RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 val colorRolesMap = mutableMapOf<Int, ColorRoles>()
 private var harmonizedContext: Context? = context

 override fun onCreateViewHolder(
   parent: ViewGroup,
   viewType: Int
 ): RecyclerView.ViewHolder {
   return if (viewType == DashboardFragment.TransitMode.BUS.ordinal) {
     BusViewHolder(LayoutInflater.from(harmonizedContext).inflate(R.layout.transit_item_bus, parent, false))
   } else TrainViewHolder(LayoutInflater.from(harmonizedContext).inflate(R.layout.transit_item_train, parent, false))
 }

 override fun getItemCount(): Int {
   return list.size
 }

 override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
   val item = list[position]
   if (item.mode.ordinal == DashboardFragment.TransitMode.BUS.ordinal) {
     (holder as BusViewHolder).bind(item)
     (holder as TransitBindable).adjustNameLength()
   } else {
       (holder as TrainViewHolder).bind(item)
       (holder as TransitBindable).adjustNameLength()
   }
 }

 override fun getItemViewType(position: Int): Int {
   return list[position].mode.ordinal
 }

 interface TransitBindable {
   val card: MaterialCardView
   var transitName: TextView
   var transitDestination: TextView

   fun bind(item: DashboardFragment.TransitInfo2) {
     transitName.text = item.name
     transitDestination.text = item.destination
   }
   fun Float.toDp(context: Context) =
     TypedValue.applyDimension(
       TypedValue.COMPLEX_UNIT_DIP,
       this,
       context.resources.displayMetrics
     )
   fun adjustNameLength(){
     if (transitName.length() > 4) {
       val layoutParams = card.layoutParams
       layoutParams.width = 100f.toDp(card.context).toInt()
       card.layoutParams = layoutParams
       transitName.setTypeface(Typeface.DEFAULT_BOLD);

       transitName.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16.0f)
     }
   }
 }

 inner class BusViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), TransitBindable {
   override val card: MaterialCardView = itemView.findViewById(R.id.card)
   override var transitName: TextView = itemView.findViewById(R.id.transitName)
   override var transitDestination: TextView = itemView.findViewById(R.id.transitDestination)
 }
 inner class TrainViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), TransitBindable {
   override val card: MaterialCardView = itemView.findViewById(R.id.card)
   override var transitName: TextView = itemView.findViewById(R.id.transitName)
   override var transitDestination: TextView = itemView.findViewById(R.id.transitDestination)
 }
}

11. UI の例

調和なしのデフォルトのテーマ設定とカスタムカラー

a5a02a72aef30529.png

調和されたカスタムカラー

4ac88011173d6753.png d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

12. まとめ

この Codelab では、以下のことを学びました。

  • 色の調和アルゴリズムの基本
  • 指定された色からカラーロールを生成する方法。
  • ユーザー インターフェースで色を選択的に調和させる方法。
  • テーマ内の属性セットを調和させる方法。

不明な点がある場合は、Twitter の @MaterialDesign までいつでもお気軽にお問い合わせください

その他のデザインに関するコンテンツやチュートリアルについては、youtube.com/MaterialDesign をご覧ください。