Android View 中的基本色彩統合

1. 事前準備

在本程式碼研究室中,您將瞭解如何透過動態主題產生的自訂色彩,調整自訂色彩。

必要條件

開發人員

  • 熟悉 Android 中的基本主題設定概念
  • 熟悉 Android 小工具檢視畫面及其屬性

課程內容

  • 如何在應用程式中使用多種方式調色
  • 協調的運作方式及顏色轉換方式

軟硬體需求

  • 使用已安裝 Android 的電腦參與遊戲。

2. 應用程式總覽

Voyardi 是採用動態主題的大眾運輸應用程式,對許多大眾運輸系統來說,顏色是火車、公車或電車的重要指標,一旦有動態的主要、二級或三元顏色,都無法替換為這些顏色。我們會著重在彩色大眾運輸票證的 RecyclerView。

62ff4b2fb6c9e14a.png

3. 產生主題

建議您先使用我們的 Material 主題建構工具,著手建立 Material3 主題。你現在可以在自訂分頁中為主題加入更多顏色。右邊會顯示這些顏色的色彩角色和色調調色盤。

您可以在延伸顏色部分中移除或重新命名顏色。

20cc2cf72efef213.png

匯出選單會顯示數個可用的匯出選項。在本文撰寫期間,質感設計主題建構工具的特殊處理設定僅適用於 Android View

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>

在 theme.xml 中,我們為每個自訂顏色 (color<name>, colorOn<name>, color<name>Container, and colorOn<nameContainer>) 產生四個顏色角色。harmonize<name> 屬性會反映開發人員是否在 Material Design 主題設定建構工具中選取相關選項。這不會改變核心主題的顏色。

<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 Design 主題設定建構工具的側邊面板,可以看到新增自訂色彩表面的面板,其中含有淺色和深色調色盤中有四個主要顏色角色。

c6ee942b2b93cd92.png

在 Android 檢視畫面中,我們會為您匯出這些顏色,但背景可以用 ColorRoles 物件的執行個體表示。

ColorRole 類別有四種屬性:accentonAccentaccentContaineronAccentContainer。這些屬性是四個十六進位顏色的整數。

public final class ColorRoles {

  private final int accent;
  private final int onAccent;
  private final int accentContainer;
  private final int onAccentContainer;

  // truncated code

}

您可以在執行階段使用名為 getColorRoles 的 MaterialColors 類別中的 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. 手動合成色彩

為了協調單一語氣,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 Design 主題設定建構工具的匯出主題中,我們使用 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. 自動統合佈景主題屬性

目前為止顯示的方法仰賴從個別顏色擷取顏色角色。如果應用程式生成了自然色調,但大多數現有應用程式卻看不清,這是很有用的。您可能不會直接產生顏色,而是使用現有的佈景主題屬性。

在本程式碼研究室中,我們先前已說明如何匯出主題屬性。

<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. UI 範例

未採用統合的預設主題設定和自訂顏色

a5a02a72aef30529.png

調和自訂顏色

4ac88011173d6753.png d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

12. 摘要

在本程式碼研究室中,您已瞭解以下內容:

  • 色彩調和演算法的基本概念
  • 如何從特定顏色產生顏色角色。
  • 如何在使用者介面中選擇性地協調色彩。
  • 如何協調主題中的一組屬性。

如有任何疑問,歡迎隨時透過 Twitter 上的 @MaterialDesign 提問。

更多設計內容和教學課程即將在 youtube.com/MaterialDesign 上線,敬請期待!