Android View 中的基本颜色协调

1. 准备工作

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

前提条件

开发者应

  • 熟悉 Android 中的基本主题概念
  • 轻松自如地使用 Android widget View 及其属性

学习内容

  • 如何通过多种方法在应用中使用颜色协调功能
  • 协调功能的工作原理及其改变颜色的方式

所需条件

  • 一台安装了 Android 的电脑(如果您想了解这些指南)。

2. 应用概览

VoyaGi 是一款已经使用了动态主题的公交应用。对于许多公交系统来说,颜色是火车、公交车或有轨电车的重要指标,不能替换为任何可用的动态主色、辅色或第三色。我们将重点关注彩色公交卡的 RecyclerView。

62ff4b2fb6c9e14a

3. 生成主题

我们建议您使用我们的 Material 主题构建器工具作为创建 Material3 主题的第一站。在自定义标签中,您现在可以向主题添加更多颜色。右侧将显示这些颜色的颜色角色和色调调色板。

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

20cc2cf72efef213/

导出菜单会显示多种可能的导出选项。在撰写本文时,Material Theme Builder 对协调设置进行的特殊处理仅适用于 Android View

6c962ad528c09b4

了解新的导出值

为了让您能够在主题中使用这些颜色及其相关的颜色角色(无论您是否选择进行协调),导出的下载内容现在包含一个 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>

在 topics.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 View 中,我们会为您导出这些颜色,但在后台,它们可以通过 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 类中名为 getColorRolesgetColorRoles 从任意颜色中检索 4 种关键颜色角色,借助该类,您可以在给定特定种子颜色的情况下在运行时创建这组 4 种颜色角色。

public static ColorRoles getColorRoles(
    @NonNull Context context,
    @ColorInt int color
) { /* implementation */ }

同样,输出值是实际颜色值,而不是指向它们的指针。**

5. 什么是颜色协调?

Material 的新颜色系统采用算法设计,可根据指定的种子颜色生成主色、辅色、第三色和中性色。在与内外部合作伙伴讨论时,我们有很多担忧的一点是,如何在拥抱动态配色的同时保持对某些颜色的控制。

这些颜色在应用中通常具有特定的含义或上下文,如果被随机颜色替换,这些含义或上下文将会丢失。或者,如果保持不变,这些颜色看起来可能会显得刺眼或格格不入。

Material You 中的颜色由色相、色度和色调来描述。颜色的色相与人们对它的认知有关,即不同的颜色范围。色调指的是颜色的明暗程度,而色度则代表颜色的强度。文化和语言因素对色相的感知可能会受到文化和语言因素的影响,例如人们经常提到在古代文化中缺少“蓝色”一词,反而出现在与绿色相同的语系中。

57c46d9974c52e4a特定色调可被视为暖色调或冷色调,具体取决于它在色谱谱内的位置。偏向红色、橙色或黄色调一般会被认为是变暖色调,而偏向蓝色、绿色或紫色色调会让颜色变冷。即使是暖色或冷色,也要采用暖色调和冷色调。下方是“暖色”黄色的色调会偏橙色,而“更冷”而黄色更受绿色影响597c6428ff6b9669

颜色协调算法会检查未偏移的颜色的色相以及应该与之协调的颜色,以找到协调一致但又不会改变其底层颜色质量的色相。在第一张图中,在光谱上绘制的绿色、黄色和橙色色调不太和谐。在下一个图形中,绿色和橙色已经与黄色调和谐。新绿色更温暖,新橙色更冷。

橙色和绿色上的色调已发生移位,但仍可以视为橙色和绿色。

766516c321348a7c

如果您想详细了解一些设计决策、探索和考虑因素,我的同事 Ayan Daniels 和 Andrew Lu 撰写了一篇博文,其中比这部分更深入

6. 手动协调颜色

为了协调一种色调,MaterialColorsharmonizeharmonizeWithPrimary 中有两个函数。

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

存储公交数据

为了存储公交卡的文本数据和颜色信息,我们使用了存储名称、目的地和颜色资源 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. 自动协调颜色

除了手动处理协调之外,您也可以让 Google 为您处理。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 d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

12. 总结

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

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

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

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