Android View 中的基本颜色协调

1. 准备工作

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

前提条件

开发者应具备以下条件:

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

学习内容

  • 了解如何使用多种方法在应用中使用颜色协调
  • 了解协调的工作原理以及如何调整颜色

所需条件

  • 如果您想跟着一起操作,则需要一台安装了 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

存储公交数据

为了存储交通卡的文本数据和颜色信息,我们使用了一个数据类来存储名称、目的地和颜色资源 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,了解更多设计内容和教程