Android View 中的基本颜色协调

1. 准备工作

在此 Codelab 中,您将学习如何协调自定义颜色与动态主题生成的颜色。

前提条件

开发者应

  • 熟悉 Android 中的基本主题设置概念
  • 能够熟练使用 Android widget 视图及其属性

学习内容

  • 如何使用多种方法在应用中实现色彩协调
  • 色彩调和的原理及其如何改变颜色

所需条件

  • 一台安装了 Android 的电脑(如果您想跟着操作)。

2. 应用概览

Voyaĝi 是一款已使用动态主题的公交应用。对于许多公交系统,颜色是火车、公交车或有轨电车的重要指示符,这些颜色无法被任何可用的动态主色、副色或第三色所取代。我们将重点处理彩色公交卡片的 RecyclerView。

62ff4b2fb6c9e14a.png

3. 生成主题

我们建议您首先使用我们的工具 Material Theme Builder 来制作 Material3 主题。在“自定义”标签页中,您现在可以向主题添加更多颜色。右侧会显示这些颜色的颜色角色和色调调色板。

在扩展颜色部分,您可以移除或重命名颜色。

20cc2cf72efef213.png

导出菜单将显示多个可能的导出选项。在撰写本文时,Material Theme Builder 对协调设置的特殊处理仅在 Android 视图中可用

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 中,我们为每种自定义颜色 (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 的侧边栏,我们可以看到,添加自定义颜色会显示一个面板,其中包含浅色和深色调色板中的四种关键颜色角色。

c6ee942b2b93cd92.png

在 Android 视图中,我们会为您导出这些颜色,但在幕后,它们可以用 ColorRoles 对象的实例来表示。

ColorRoles 类具有四个属性:accentonAccentaccentContaineronAccentContainer。这些属性是四种十六进制颜色的整数表示形式。

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)从任意颜色检索四种关键颜色角色,该方法允许您在运行时根据特定的种子颜色创建这组四种颜色角色。

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. 手动协调颜色

如需使单个音调和谐,MaterialColors 中有两个函数:harmonizeharmonizeWithPrimary

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

如需检索这组四种音调,我们需要再做一些工作。

鉴于我们已经有了源颜色,我们需要执行以下操作:

  1. 确定是否应进行协调,
  2. 确定是否处于深色模式,以及
  3. 返回已协调或未协调的 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

存储 Transit 数据

为了存储公交卡上的文字数据和颜色信息,我们使用了一个数据类来存储名称、目的地和颜色资源 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。这两种方法之间有一个主要区别。此外,我们还需要提供包含要协调的字段的主题叠加层。

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. 界面示例

不进行协调的默认主题和自定义颜色

a5a02a72aef30529.png

协调的自定义颜色

4ac88011173d6753.png d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

12. 总结

在此 Codelab 中,您学习了以下内容:

  • 色彩协调算法的基础知识
  • 如何根据给定的颜色生成颜色角色。
  • 如何有选择地协调界面中的颜色。
  • 如何协调主题中的一组属性。

如果您有任何疑问,请随时通过 Twitter 上的 @MaterialDesign 向我们咨询。

敬请关注 youtube.com/MaterialDesign,了解更多设计内容和教程