1. Before you begin
In this codelab, you'll learn how to harmonize your custom colors with those generated by a dynamic theme.
Prerequisites
Developers should be
- Familiar with basic theming concepts in Android
- Comfortable working with Android widget Views and their properties
What you'll learn
- How to use color harmonization in your application using multiple methods
- How harmonization works and how it shifts color
What you'll need
- A computer with Android installed if you'd like to follow along.
2. App Overview
Voyaĝi is a transit application that already uses a dynamic theme. For a lot of transit systems, color is an important indicator of trains, buses or trams and these can't be replaced by whatever dynamic primary, secondary, or tertiary colors are available. We'll be focusing our work on the RecyclerView of colored transit cards.
3. Generating a Theme
We recommend using our tool Material Theme Builder as your first stop to make a Material3 theme. On the custom tab, you can now add more colors to your theme. On the right, you will be shown the color roles and tonal palettes for those colors.
In the extended color section, you can remove or rename colors.
The export menu will display a number of possible export options. At the time of writing, Material Theme Builder's special handling of harmonization settings is only available in Android Views
Understanding the new export values
To allow you to use these colors and their associated color roles in your themes whether or not you choose to harmonize, the exported download now includes an attrs.xml file containing the color role names for each custom color.
<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>
In themes.xml, we have generated the four color roles for each custom color (color<name>, colorOn<name>, color<name>Container, and colorOn<nameContainer>
). harmonize<name>
properties reflect whether the developer has selected the option in Material Theme Builder. It will not shift the color in the core theme.
<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>
In the colors.xml
file, the seed colors used to generate the color roles listed above are specified along with boolean values for if the color's palette will be shifted or not.
<resources>
<!-- other colors used in theme -->
<color name="custom1">#1AC9E0</color>
<color name="custom2">#32D312</color>
</resources>
4. Examining Custom Color
Zooming into the side panel of Material Theme Builder, we can see that adding a custom color surfaces a panel with the four key color roles in a light and dark palette.
In Android Views, we export these colors for you but behind the scenes they can be represented by an instance of the ColorRoles
object.
The ColorRoles class has four properties, accent
, onAccent
, accentContainer
, and onAccentContainer
. These properties are the integer representation of the four hexidecimal colors.
public final class ColorRoles {
private final int accent;
private final int onAccent;
private final int accentContainer;
private final int onAccentContainer;
// truncated code
}
You can retrieve the four key color roles from an arbitrary color at runtime using getColorRoles
in the MaterialColors class called getColorRoles
that allows you to create that set of four color roles at runtime given a specific seed color.
public static ColorRoles getColorRoles(
@NonNull Context context,
@ColorInt int color
) { /* implementation */ }
Likewise the output values are the actual color values, NOT pointers to them.**
5. What is Color Harmonization?
Material's new color system is algorithmic by design, generating primary, secondary, tertiary, and neutral colors from a given seed color. One point of concern that we received a lot when talking to internal and external partners was how to embrace dynamic color while keeping control over some colors.
These colors often carry a specific meaning or context in the application that would be lost if they were replaced by a random color. Alternatively, if left as is, these colors might look visually jarring or out of place.
Color in Material You is described by hue, chroma, and tone. A color's hue relates to one's perception of it as a member of one color range versus another. Tone describes how light or dark it appears and chroma is the intensity of color. Perception of hue can be affected by cultural and linguistic factors, like the oft mentioned lack of a word for blue in ancient cultures with it instead being seen in the same family as green.
A particular hue can be considered warm or cool depending on where it sits on the hue spectrum. Shifting towards a red, orange, or yellow hue is generally considered making it warmer and towards a blue, green, or purple is said to be making it cooler. Even within the warm or cool colors, you will have warm and cool tones. Below, the "warmer" yellow is more orange tinted whereas the "cooler" yellow is more influenced by green.
The color harmonization algorithm examines the hue of the unshifted color and the color it should be harmonized with to locate a hue that is harmonious but doesn't alter its underlying color qualities. In the first graphic, there are less harmonious green, yellow and orange hues plotted on a spectrum. In the next graphic, the green and orange have been harmonized with the yellow hue. The new green is more warm and the new orange is more cool.
The hue has shifted on the orange and green but they still can be perceived as orange and green.
If you'd like to learn more about some of the design decisions, explorations, and considerations my colleagues Ayan Daniels and Andrew Lu have written a blog post going a bit more in depth than this section.
6. Harmonizing a color manually
To harmonize a single tone, there are two functions in MaterialColors
, harmonize
and harmonizeWithPrimary
.
harmonizeWithPrimary
uses the Context
as a means to access the current theme and subsequently the primary color from it.
@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);
}
To retrieve the set of four tones, we need to do a little bit more.
Given we already have the source color, we need to:
- determine if it should be harmonized,
- determine if we are in dark mode, and,
- return either a harmonized or un-harmonized
ColorRoles
object.
Determining whether to harmonize
In the exported theme from Material Theme Builder, we included boolean attributes using the nomenclature harmonize<Color>
. Below is a convenience function to access that value.
If found, it returns its value; else it determines that it shouldn't harmonize the 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
}
Creating a Harmonized ColorRoles
object
retrieveHarmonizedColorRoles
is another convenience function joining all the aforementioned steps: retrieving the color value for a named resource, attempts to resolve a boolean attribute to determine harmonization, and returns a ColorRoles
object derived from the original or blended color (given light or dark scheme).
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. Populating transit cards
As mentioned before, we'll be using a RecyclerView and adapter to populate and color the collection of transit cards.
Storing Transit data
To store the text data and color information for the transit cards, we're using a data class storing the name, destination, and color resource 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)
)
We'll use this color to generate the tones we need in real-time.
You could harmonize at runtime with the following onBindViewHolder
function.
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. Harmonizing colors automatically
An alternative to handling harmonization manually, you can have it handled for you. HarmonizedColorOptions is a builder class that allows you to specify much of what we've thus far done by hand.
After retrieving the current context so you have access to the current dynamic scheme, you need to specify the base colors you want to harmonize and create a new context based on that HarmonizedColorOptions object and the DynamicColors enabled context.
If you don't want to harmonize a color, simply don't include it in harmonizedOptions.
val newContext = DynamicColors.wrapContextIfAvailable(requireContext())
val harmonizedOptions = HarmonizedColorsOptions.Builder()
.setColorResourceIds(intArrayOf(R.color.custom1, R.color.custom2))
.build();
harmonizedContext =
HarmonizedColors.wrapContextIfAvailable(dynamicColorsContext, harmonizedOptions)
With the harmonized base color already handled you could update your onBindViewHolder to simply call MaterialColors.getColorRoles
and specify if the returned roles should be light or dark.
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. Harmonizing theme attributes automatically
The methods shown up until now rely on retrieving the color roles from an individual color. That's great for showing that a real tone is being generated but not realistic to most existing applications. You will be likely not deriving a color directly but instead using an existing theme attribute.
Earlier in this codelab, we talked about exporting theme attributes.
<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>
Similar to the first automatic method, we can provide values to HarmonizedColorOptions and use HarmonizedColors to retrieve a Context with the harmonized colors. There's one key difference between the two methods. We additionally need to provide a theme overlay containing the fields to be harmonized.
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)
Your adapter would use the harmonized context. The values in the theme overlay should refer to the unharmonized light or dark variant.
<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>
Inside the XML layout file, we can use those harmonized attributes as normal.
<?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. Source Code
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. Example UIs
Default Theming and Custom Colors with no Harmonization
Harmonized Custom Colors
12. Summary
In this codelab, you've learned:
- The basics of our color harmonization algorithm
- How to generate color roles from a given seen color.
- How to selectively harmonize a color in a user interface.
- How to harmonize a set of attributes in a theme.
If you've got questions, feel free to ask us any time using @MaterialDesign on Twitter.
Stay tuned for more design content and tutorials on youtube.com/MaterialDesign