Basic Color Harmonization in Android Views

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
  • 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.

62ff4b2fb6c9e14a.png

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.

67e13bc30aa60fd5.png

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

bf969343bfd3ba6.png

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>).

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

       <item name="colorCustom1">#983591</item>
       <item name="colorOnCustom1">#ffffff</item>
       <item name="colorCustom1Container">#ffd6f6</item>
       <item name="colorOnCustom1Container">#380038</item>
       <item name="harmonizeCustom1">false</item>

       <item name="colorCustom2">#9e3e44</item>
       <item name="colorOnCustom2">#ffffff</item>
       <item name="colorCustom2Container">#ffd9da</item>
       <item name="colorOnCustom2Container">#400008</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">#83217E</color>
   <color name="custom2">#731E26</color>
</resources>

4. 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.

57c46d9974c52e4a.pngA 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. ef46dadfe6a9e450.png

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.

9ac72c3fa6b3123d.png

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.

5. Generating colors from a seed color

In Material Theme Builder, adding a custom color surfaced a panel with four color roles.

668d0cb0d38b8d7e.png

Material Design Components has an associated API 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 */ }

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

}

Likewise the output values are the actual color values, NOT pointers to them.**

6. Populating transit cards

As mentioned before, we'll be using a RecyclerView and adapter to populate and color the collection of transit cards.

ba69bd2af5f1dc14.png

Storing Transit data

To store the text data and color information for the transit cards, we're using 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.

Creating the Item View

Each transit card consists of two TextViews contained in a MaterialCardView.

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView ...
   style="?attr/materialCardViewFilledStyle"
   android:id="@+id/card"
   android:layout_width="80dp"
   android:layout_height="100dp"
   android:layout_marginStart="8dp"
   >

   <androidx.constraintlayout.widget.ConstraintLayout
       android:layout_width="match_parent"

       android:layout_height="match_parent"
       android:layout_margin="8dp">

       <TextView
           android:id="@+id/transitName" ... />

       <TextView
           android:id="@+id/transitDestination"
           ... />
   </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

Creating the Adapter

Our starting adapter looks like any other RecyclerView.Adapter you might write with one key addition, a Map storing ColorRoles objects indexed by color resource id.

Our ViewHolder instantiates the MaterialCardView and TextViews, for which we'll be adjusting color.

class TransitCardAdapter(val list: List<TransitInfo>) :
   RecyclerView.Adapter<TransitCardAdapter.ViewHolder>() {
   
   val colorRolesMap = mutableMapOf<Int, ColorRoles>()

   override fun onCreateViewHolder( /* truncated */ }

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

   override fun onBindViewHolder(holder: ViewHolder, position: Int) {
       /* truncated */
   }

   class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
       val card: MaterialCardView = itemView.findViewById(R.id.card)
       var transitName: TextView = 
               itemView.findViewById(R.id.transitName)
       var transitDestination: TextView = 
               itemView.findViewById(R.id.transitDestination)

   }
}

7. Coloring and Harmonizing the Transit Cards

To properly harmonize a given color, we need to do a couple things:

  1. retrieve the color
  2. determine if it should be harmonized,
  3. determine if we are in dark mode, and,
  4. 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>. You have to include the function below which locates the string name for the given color, capitalizes its first character, prefixes "harmonize", and looks for a resource with that concatenated identifier.

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 retrieves 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.

harmonizeWithPrimary uses the Context as a means to access the current theme and subsequently the primary color from it.

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

8. Putting it all together

The onBindViewHolder of the adapter does the following:

  1. Retrieves a TransitInfo object from the collection.
  2. Checks if it has already been resolved to a ColorRoles object, storing one if it has not been.
  3. Sets the background color of the card and text colors of the TextViews to the generated container color and on container color, respectively.
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)
   }
}

Default Theming and Custom Colors with no Harmonization

906562fa84e7c29.png a5a02a72aef30529.png

Harmonized Custom Colors

4ac88011173d6753.png d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

9. 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.

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