1. 始める前に
この Codelab では、カスタムカラーを動的テーマで生成されたカラーと調和させる方法を学びます。
前提条件
デベロッパーは
- Android の基本的なテーマ設定のコンセプトを理解している
- Android ウィジェットの View とそのプロパティを快適に操作できる
学習内容
- 複数の方法を使用してアプリでカラー ハーモナイゼーションを使用する方法
- 調和の仕組みと色の変化
必要なもの
- Android がインストールされているパソコン(手順に沿って操作する場合)。
2. アプリの概要
Voyaĝi は、すでに動的テーマを使用している交通機関アプリです。多くの交通機関では、電車、バス、路面電車の重要な指標として色が使われています。そのため、利用可能な動的なプライマリ カラー、セカンダリ カラー、ターシャリ カラーに置き換えることはできません。ここでは、色の付いた乗車券の RecyclerView に焦点を当てます。

3. テーマの生成
Material3 テーマを作成する際は、まず マテリアル テーマ ビルダー ツールを使用することをおすすめします。[カスタム] タブで、テーマに色を追加できるようになりました。右側には、それらの色のカラーロールと色調パレットが表示されます。
拡張カラー セクションでは、カラーを削除したり、名前を変更したりできます。

エクスポート メニューに、エクスポート可能なオプションがいくつか表示されます。この記事の執筆時点では、マテリアル テーマビルダーの調和設定の特別な処理は Android ビューでのみ利用可能です。

新しいエクスポート値について
調和させるかどうかに関係なく、テーマでこれらの色と関連する色の役割を使用できるように、エクスポートされたダウンロードに、各カスタムカラーの色の役割名を含む 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 では、各カスタムカラー(color<name>, colorOn<name>, color<name>Container, and colorOn<nameContainer>)の 4 つのカラーロールを生成しました。harmonize<name> プロパティは、デベロッパーがマテリアル テーマビルダーでオプションを選択したかどうかを反映します。コアテーマの色は変更されません。
<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. カスタムカラーの確認
マテリアル テーマビルダーのサイドパネルを拡大すると、カスタムカラーを追加すると、ライト パレットとダーク パレットの 4 つのキーカラーの役割を示すパネルが表示されることがわかります。

Android Views では、これらの色がエクスポートされますが、内部的には ColorRoles オブジェクトのインスタンスで表されます。
ColorRoles クラスには、accent、onAccent、accentContainer、onAccentContainer の 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
}
MaterialColors クラスの getColorRoles で getColorRoles を使用すると、実行時に任意の色の 4 つのキーカラーロールを取得できます。これにより、特定のシードカラーを指定して、実行時に 4 つのカラーロールのセットを作成できます。
public static ColorRoles getColorRoles(
@NonNull Context context,
@ColorInt int color
) { /* implementation */ }
同様に、出力値は実際の色の値であり、それらへのポインタではありません。
5. カラー ハーモナイゼーションとは
マテリアルの新しいカラーシステムは、アルゴリズムによって設計されており、指定されたシードカラーからプライマリ カラー、セカンダリ カラー、ターシャリ カラー、ニュートラル カラーを生成します。社内外のパートナーと話しているときに多く寄せられた懸念事項の 1 つは、一部の色を制御しながらダイナミック カラーを採用する方法でした。
これらの色は、多くの場合、アプリ内で特定の意味やコンテキストを持っており、ランダムな色に置き換えると、その意味やコンテキストが失われてしまいます。そのままにすると、これらの色が視覚的に不調和に見えたり、場違いに見えたりする可能性があります。
Material You の色は、色相、彩度、トーンで表されます。色の色相は、ある色域に属する色と別の色域に属する色を区別する人間の知覚に関連しています。トーンは明るさ、彩度は色の鮮やかさを表します。色相の認識は、文化や言語の要因によって影響を受ける可能性があります。たとえば、古代文化では青を表す言葉がなく、緑と同じ系統の色と見なされていたことがよく知られています。
特定の色相は、色相スペクトルのどこに位置するかによって暖色または寒色と見なされます。赤、オレンジ、黄色の色合いにシフトすると暖かくなり、青、緑、紫の色合いにシフトすると涼しくなると一般的に考えられています。暖色系や寒色系の中でも、暖色系の色調と寒色系の色調があります。下の「暖色」の黄色はオレンジがかった色で、「寒色」の黄色は緑の影響を強く受けています。
カラー ハーモナイゼーション アルゴリズムは、シフトされていない色と調和させるべき色の色相を調べ、調和が取れていて、基になる色の品質を変更しない色相を見つけます。最初のグラフでは、調和の取れていない緑、黄、オレンジの色相がスペクトル上にプロットされています。次の図では、緑とオレンジが黄色の色相で調和しています。新しい緑はより暖かく、新しいオレンジはよりクールです。
オレンジと緑の色相はシフトしていますが、オレンジと緑として認識できます。

設計上の決定、調査、考慮事項について詳しくは、同僚の Ayan Daniels と Andrew Lu が執筆したブログ投稿をご覧ください。このセクションよりも詳しく説明されています。
6. 色を手動で調和させる
単一のトーンを調和させるために、MaterialColors には harmonize と harmonizeWithPrimary の 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 つのトーンのセットを取得するには、もう少し作業が必要です。
ソースカラーはすでに用意されているため、次の手順を行います。
- 調和させるかどうかを判断し、
- ダークモードかどうかを判断し、
- 調和された
ColorRolesオブジェクトまたは調和されていないColorRolesオブジェクトのいずれかを返します。
調和させるかどうかを判断する
マテリアル テーマ ビルダーからエクスポートされたテーマでは、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 とアダプタを使用して、交通機関カードのコレクションを生成して色付けします。

乗換案内データの保存
乗車券のテキストデータと色情報を保存するために、名前、目的地、色リソース 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 の例
デフォルトのテーマ設定とカスタムカラー(調和なし)

調和したカスタムカラー


12. まとめ
この Codelab では、以下のことを学びました。
- カラー ハーモナイゼーション アルゴリズムの基本
- 指定された色からカラーロールを生成する方法。
- ユーザー インターフェースで色を調和させる方法。
- テーマ内の属性のセットを調和させる方法。
不明な点がある場合は、Twitter の @MaterialDesign までいつでもお気軽にお問い合わせください。
その他のデザインに関するコンテンツやチュートリアルについては、youtube.com/MaterialDesign をご覧ください。