1. Avant de commencer
Dans cet atelier de programmation, vous allez apprendre à harmoniser vos couleurs personnalisées avec celles générées par un thème dynamique.
Prérequis
Les développeurs doivent :
- connaître les concepts de base de la thématisation dans Android ;
- être à l'aise avec les vues de widgets Android et leurs propriétés.
Points abordés
- Utiliser l'harmonisation des couleurs dans votre application à l'aide de plusieurs méthodes
- Comprendre le fonctionnement de l'harmonisation et la façon dont elle modifie les couleurs
Ce dont vous avez besoin
- Un ordinateur sur lequel Android est installé si vous souhaitez suivre cet atelier.
2. Présentation de l'application
Voyaĝi est une application de transport qui utilise déjà un thème dynamique. Pour de nombreux systèmes de transport, la couleur est un indicateur important pour les trains, les bus ou les tramways, et elle ne peut pas être remplacée par les couleurs primaires, secondaires ou tertiaires dynamiques disponibles. Nous allons nous concentrer sur le RecyclerView des cartes de transport colorées.

3. Générer un thème
Nous vous recommandons d'utiliser notre outil Material Theme Builder pour créer un thème Material 3. Dans l'onglet "Custom" (Personnalisé), vous pouvez désormais ajouter d'autres couleurs à votre thème. À droite, vous verrez les rôles de couleur et les palettes de tons pour ces couleurs.
Dans la section "Extended color" (Couleur étendue), vous pouvez supprimer ou renommer des couleurs.

Le menu d'exportation affiche plusieurs options d'exportation possibles. Au moment de la rédaction de cet article, la gestion spéciale des paramètres d'harmonisation de Material Theme Builder n'est disponible que dans les vues Android.

Comprendre les nouvelles valeurs d'exportation
Pour vous permettre d'utiliser ces couleurs et leurs rôles de couleur associés dans vos thèmes, que vous choisissiez ou non de les harmoniser, le téléchargement exporté inclut désormais un fichier attrs.xml contenant les noms des rôles de couleur pour chaque couleur personnalisée.
<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>
Dans themes.xml, nous avons généré les quatre rôles de couleur pour chaque couleur personnalisée (color<name>, colorOn<name>, color<name>Container, and colorOn<nameContainer>). Les propriétés harmonize<name> indiquent si le développeur a sélectionné l'option dans Material Theme Builder. La couleur ne sera pas modifiée dans le thème principal.
<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>
Dans le fichier colors.xml, les couleurs de base utilisées pour générer les rôles de couleur listés ci-dessus sont spécifiées, ainsi que des valeurs booléennes indiquant si la palette de couleurs sera modifiée ou non.
<resources>
<!-- other colors used in theme -->
<color name="custom1">#1AC9E0</color>
<color name="custom2">#32D312</color>
</resources>
4. Examiner la couleur personnalisée
En zoomant sur le panneau latéral de Material Theme Builder, nous constatons que l'ajout d'une couleur personnalisée fait apparaître un panneau avec les quatre rôles de couleur clés dans une palette claire et une palette sombre.

Dans les vues Android, nous exportons ces couleurs pour vous, mais en arrière-plan, elles peuvent être représentées par une instance de l'objet ColorRoles.
La classe ColorRoles comporte quatre propriétés : accent, onAccent, accentContainer, et onAccentContainer. Ces propriétés sont la représentation entière des quatre couleurs hexadécimales.
public final class ColorRoles {
private final int accent;
private final int onAccent;
private final int accentContainer;
private final int onAccentContainer;
// truncated code
}
Vous pouvez récupérer les quatre rôles de couleur clés à partir d'une couleur arbitraire au moment de l'exécution à l'aide de getColorRoles dans la classe MaterialColors appelée getColorRoles, qui vous permet de créer cet ensemble de quatre rôles de couleur au moment de l'exécution à partir d'une couleur de base spécifique.
public static ColorRoles getColorRoles(
@NonNull Context context,
@ColorInt int color
) { /* implementation */ }
De même, les valeurs de sortie sont les valeurs de couleur réelles, et NON des pointeurs vers celles-ci.**
5. Qu'est-ce que l'harmonisation des couleurs ?
Le nouveau système de couleurs de Material est conçu de manière algorithmique. Il génère des couleurs primaires, secondaires, tertiaires et neutres à partir d'une couleur de base donnée. L'une des préoccupations que nous avons souvent entendue lorsque nous avons discuté avec des partenaires internes et externes était de savoir comment adopter la couleur dynamique tout en gardant le contrôle sur certaines couleurs.
Ces couleurs ont souvent une signification ou un contexte spécifiques dans l'application, qui seraient perdus si elles étaient remplacées par une couleur aléatoire. Si elles sont laissées telles quelles, ces couleurs peuvent sembler visuellement discordantes ou déplacées.
Dans Material You, la couleur est décrite par la teinte, la chroma et le ton. La teinte d'une couleur est liée à la perception qu'une personne en a en tant que membre d'une plage de couleurs plutôt que d'une autre. Le ton décrit la luminosité ou l'obscurité de la couleur, et la chroma est son intensité. La perception de la teinte peut être affectée par des facteurs culturels et linguistiques, comme le manque souvent mentionné d'un mot pour le bleu dans les cultures anciennes, où il était plutôt considéré comme appartenant à la même famille que le vert.
Une teinte particulière peut être considérée comme chaude ou froide selon sa position dans le spectre des teintes. Une teinte rouge, orange ou jaune est généralement considérée comme plus chaude, tandis qu'une teinte bleue, verte ou violette est considérée comme plus froide. Même au sein des couleurs chaudes ou froides, vous aurez des tons chauds et froids. Ci-dessous, le jaune "plus chaud" est plus teinté d'orange, tandis que le jaune "plus froid" est plus influencé par le vert. 
L'algorithme d'harmonisation des couleurs examine la teinte de la couleur non modifiée et la couleur avec laquelle elle doit être harmonisée pour trouver une teinte harmonieuse, mais qui n'altère pas ses qualités de couleur sous-jacentes. Dans le premier graphique, des teintes de vert, de jaune et d'orange moins harmonieuses sont représentées sur un spectre. Dans le graphique suivant, le vert et l'orange ont été harmonisés avec la teinte jaune. Le nouveau vert est plus chaud et le nouvel orange est plus froid.
La teinte a été modifiée sur l'orange et le vert, mais ils peuvent toujours être perçus comme orange et vert.

Si vous souhaitez en savoir plus sur certaines décisions de conception, explorations et considérations, mes collègues Ayan Daniels et Andrew Lu ont écrit un article de blog qui approfondit un peu plus cette section.
6. Harmoniser une couleur manuellement
Pour harmoniser un seul ton, il existe deux fonctions dans MaterialColors, harmonize et harmonizeWithPrimary.
harmonizeWithPrimary utilise le Context pour accéder au thème actuel, puis à sa couleur principale.
@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);
}
Pour récupérer l'ensemble des quatre tons, nous devons faire un peu plus.
Étant donné que nous avons déjà la couleur source, nous devons :
- déterminer si elle doit être harmonisée ;
- déterminer si nous sommes en mode sombre ;
- renvoyer un objet
ColorRolesharmonisé ou non harmonisé.
Déterminer s'il faut harmoniser
Dans le thème exporté à partir de Material Theme Builder, nous avons inclus des attributs booléens à l'aide de la nomenclature harmonize<Color>. Vous trouverez ci-dessous une fonction pratique pour accéder à cette valeur.
Si elle est trouvée, elle renvoie sa valeur. Sinon, elle détermine qu'elle ne doit pas harmoniser la couleur.
// 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
}
Créer un objet ColorRoles harmonisé
retrieveHarmonizedColorRoles est une autre fonction pratique qui regroupe toutes les étapes mentionnées ci-dessus : récupération de la valeur de couleur pour une ressource nommée, tentative de résolution d'un attribut booléen pour déterminer l'harmonisation et renvoi d'un objet ColorRoles dérivé de la couleur d'origine ou de la couleur mélangée (selon le schéma clair ou sombre).
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. Remplir les cartes de transport
Comme mentionné précédemment, nous allons utiliser un RecyclerView et un adaptateur pour remplir et colorer la collection de cartes de transport.

Stocker les données de transport
Pour stocker les données textuelles et les informations de couleur des cartes de transport, nous utilisons une classe de données qui stocke le nom, la destination et l'ID de ressource de couleur.
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)
)
Nous utiliserons cette couleur pour générer les tons dont nous avons besoin en temps réel.
Vous pouvez harmoniser au moment de l'exécution avec la fonction onBindViewHolder suivante.
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. Harmoniser automatiquement les couleurs
Au lieu de gérer l'harmonisation manuellement, vous pouvez la faire gérer pour vous. HarmonizedColorOptions est une classe de compilateur qui vous permet de spécifier une grande partie de ce que nous avons fait jusqu'à présent à la main.
Après avoir récupéré le contexte actuel pour avoir accès au schéma dynamique actuel, vous devez spécifier les couleurs de base que vous souhaitez harmoniser et créer un contexte basé sur cet objet HarmonizedColorOptions et le contexte DynamicColors activé.
Si vous ne souhaitez pas harmoniser une couleur, ne l'incluez pas dans harmonizedOptions.
val newContext = DynamicColors.wrapContextIfAvailable(requireContext())
val harmonizedOptions = HarmonizedColorsOptions.Builder()
.setColorResourceIds(intArrayOf(R.color.custom1, R.color.custom2))
.build();
harmonizedContext =
HarmonizedColors.wrapContextIfAvailable(dynamicColorsContext, harmonizedOptions)
Une fois la couleur de base harmonisée gérée, vous pouvez mettre à jour votre onBindViewHolder pour appeler simplement MaterialColors.getColorRoles et spécifier si les rôles renvoyés doivent être clairs ou sombres.
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. Harmoniser automatiquement les attributs de thème
Les méthodes présentées jusqu'à présent reposent sur la récupération des rôles de couleur à partir d'une couleur individuelle. C'est idéal pour montrer qu'un ton réel est généré, mais ce n'est pas réaliste pour la plupart des applications existantes. Il est probable que vous ne dériviez pas directement une couleur, mais que vous utilisiez plutôt un attribut de thème existant.
Précédemment dans cet atelier de programmation, nous avons parlé de l'exportation des attributs de thème.
<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>
Comme pour la première méthode automatique, nous pouvons fournir des valeurs à HarmonizedColorOptions et utiliser HarmonizedColors pour récupérer un contexte avec les couleurs harmonisées. Il existe une différence essentielle entre les deux méthodes. Nous devons également fournir une superposition de thème contenant les champs à harmoniser.
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)
Votre adaptateur utilisera le contexte harmonisé. Les valeurs de la superposition de thème doivent faire référence à la variante claire ou sombre non harmonisée.
<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>
Dans le fichier de mise en page XML, nous pouvons utiliser ces attributs harmonisés comme d'habitude.
<?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. Code source
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. Exemples d'UI
Thématisation par défaut et couleurs personnalisées sans harmonisation

Couleurs personnalisées harmonisées


12. Résumé
Dans cet atelier de programmation, vous avez appris :
- Les bases de notre algorithme d'harmonisation des couleurs
- Comment générer des rôles de couleur à partir d'une couleur donnée
- Comment harmoniser sélectivement une couleur dans une interface utilisateur
- Comment harmoniser un ensemble d'attributs dans un thème
Si vous avez des questions, n'hésitez pas à nous contacter à tout moment à l'adresse @MaterialDesign sur Twitter.
Suivez-nous pour d'autres contenus et tutoriels de conception sur youtube.com/MaterialDesign.