Базовая гармонизация цветов в представлениях Android

1. Прежде чем начать

В этой лабораторной работе вы узнаете, как гармонизировать пользовательские цвета с цветами, созданными динамической темой.

Предварительные условия

Разработчики должны быть

  • Знание основных концепций тем в 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 Views мы экспортируем эти цвета для вас, но «за кулисами» они могут быть представлены экземпляром объекта ColorRoles .

Класс ColorRoles имеет четыре свойства: accent , onAccent , accentContainer и onAccentContainer . Эти свойства представляют собой целочисленное представление четырех шестнадцатеричных цветов.

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

Если вы хотите узнать больше о некоторых дизайнерских решениях, исследованиях и соображениях, мои коллеги Аян Дэниелс и Эндрю Лу написали сообщение в блоге, которое будет более глубоким, чем этот раздел.

6. Гармонизация цвета вручную

Для гармонизации одного тона в MaterialColors есть две функции: harmonize и harmonizeWithPrimary .

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
}

Создание объекта Harmonized 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

Хранение транзитных данных

Чтобы хранить текстовые данные и информацию о цвете для транспортных карт, мы используем класс данных, хранящий имя, пункт назначения и идентификатор цветового ресурса.

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 для получения контекста с согласованными цветами. Между этими двумя методами есть одно ключевое различие. Кроме того, нам необходимо предоставить наложение темы, содержащее поля, которые необходимо согласовать.

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. Примеры пользовательского интерфейса

Темы по умолчанию и пользовательские цвета без гармонизации

а5а02а72aef30529.png

Гармонизированные пользовательские цвета

4ac88011173d6753.pngd5084780d2c6b886.png

dd0c8b90eccd8bef.pngc51f8a677b22cd54.png

12. Резюме

В этой лаборатории вы узнали:

  • Основы нашего алгоритма гармонизации цветов
  • Как генерировать цветовые роли из заданного видимого цвета.
  • Как выборочно гармонизировать цвет в пользовательском интерфейсе.
  • Как гармонизировать набор атрибутов в теме.

Если у вас есть вопросы, задавайте их нам в любое время, используя @MaterialDesign в Твиттере .

Оставайтесь с нами, чтобы увидеть больше материалов и руководств по дизайну на youtube.com/MaterialDesign.