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

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.

20cc2cf72efef213.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

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

c6ee942b2b93cd92.png

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.

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. 597c6428ff6b9669.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.

766516c321348a7c.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.

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:

  1. determine if it should be harmonized,
  2. determine if we are in dark mode, and,
  3. 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.

e4555089b065b5a7.png

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

a5a02a72aef30529.png

Harmonized Custom Colors

4ac88011173d6753.png d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

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