Armonización básica de colores en vistas de Android

1. Antes de comenzar

En este codelab, aprenderás a armonizar tus colores personalizados con los que genera un tema dinámico.

Requisitos previos

Los desarrolladores deben

  • Conocimiento de los conceptos básicos de temas en Android
  • Comodidad para trabajar con las Views de los widgets de Android y sus propiedades

Qué aprenderás

  • Cómo usar la armonización de colores en tu aplicación con varios métodos
  • Cómo funciona la armonización y cómo cambia el color

Requisitos

  • Una computadora con Android instalado si quieres seguir los pasos

2. Descripción general de la app

Voyaĝi es una aplicación de transporte público que ya usa un tema dinámico. En muchos sistemas de transporte público, el color es un indicador importante de trenes, autobuses o tranvías, y estos no se pueden reemplazar por los colores primarios, secundarios o terciarios dinámicos que estén disponibles. Enfocaremos nuestro trabajo en el RecyclerView de las tarjetas de transporte público de colores.

62ff4b2fb6c9e14a.png

3. Cómo generar un tema

Te recomendamos que uses nuestra herramienta Material Theme Builder como primer paso para crear un tema de Material 3. En la pestaña personalizada, ahora puedes agregar más colores a tu tema. A la derecha, se mostrarán los roles de color y las paletas tonales para esos colores.

En la sección de colores extendidos, puedes quitar o cambiar el nombre de los colores.

20cc2cf72efef213.png

En el menú de exportación, se mostrarán varias opciones posibles. En el momento de escribir este artículo, el manejo especial de la configuración de armonización de Material Theme Builder solo está disponible en Android Views.

6c962ad528c09b4.png

Cómo comprender los nuevos valores de exportación

Para permitirte usar estos colores y sus roles de color asociados en tus temas, ya sea que elijas armonizar o no, la descarga exportada ahora incluye un archivo attrs.xml que contiene los nombres de los roles de color para cada color personalizado.

<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>

En themes.xml, generamos los cuatro roles de color para cada color personalizado (color<name>, colorOn<name>, color<name>Container, and colorOn<nameContainer>). Las propiedades de harmonize<name> reflejan si el desarrollador seleccionó la opción en Material Theme Builder. No cambiará el color del tema 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>

En el archivo colors.xml, se especifican los colores semilla que se usan para generar los roles de color mencionados anteriormente, junto con valores booleanos que indican si se cambiará la paleta de colores o no.

<resources>
   <!-- other colors used in theme -->

   <color name="custom1">#1AC9E0</color>
   <color name="custom2">#32D312</color>
</resources>

4. Cómo examinar el color personalizado

Si acercamos el panel lateral de Material Theme Builder, podemos ver que, cuando se agrega un color personalizado, aparece un panel con los cuatro roles de color clave en una paleta clara y oscura.

c6ee942b2b93cd92.png

En Android Views, exportamos estos colores por ti, pero, en segundo plano, se pueden representar con una instancia del objeto ColorRoles.

La clase ColorRoles tiene cuatro propiedades: accent, onAccent, accentContainer y onAccentContainer. Estas propiedades son la representación de números enteros de los cuatro colores hexadecimales.

public final class ColorRoles {

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

  // truncated code

}

Puedes recuperar los cuatro roles de color clave de un color arbitrario en el tiempo de ejecución con getColorRoles en la clase MaterialColors llamada getColorRoles, que te permite crear ese conjunto de cuatro roles de color en el tiempo de ejecución a partir de un color semilla específico.

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

Del mismo modo, los valores de salida son los valores de color reales, NO punteros a ellos**.

5. ¿Qué es la armonización de color?

El nuevo sistema de colores de Material es algorítmico por diseño, ya que genera colores primarios, secundarios, terciarios y neutros a partir de un color semilla determinado. Un punto de preocupación que recibimos mucho cuando hablamos con socios internos y externos fue cómo adoptar el color dinámico y, al mismo tiempo, mantener el control sobre algunos colores.

Estos colores suelen tener un significado o contexto específico en la aplicación que se perdería si se reemplazaran por un color aleatorio. De lo contrario, si se dejan como están, estos colores podrían verse visualmente discordantes o fuera de lugar.

En Material You, el color se describe según el matiz, la croma y el tono. El matiz de un color se relaciona con la percepción que se tiene de él como miembro de un rango de colores en comparación con otro. El tono describe cuán claro u oscuro se ve, y el croma es la intensidad del color. La percepción del matiz puede verse afectada por factores culturales y lingüísticos, como la falta de una palabra para el azul en las culturas antiguas, en las que se lo consideraba parte de la misma familia que el verde.

57c46d9974c52e4a.pngUn tono en particular puede considerarse cálido o frío según su ubicación en el espectro de tonos. Por lo general, se considera que cambiar hacia un tono rojo, naranja o amarillo lo hace más cálido, y hacia un azul, verde o morado lo hace más frío. Incluso dentro de los colores cálidos o fríos, tendrás tonos cálidos y fríos. A continuación, el amarillo “más cálido” tiene un tono más anaranjado, mientras que el amarillo “más frío” está más influenciado por el verde. 597c6428ff6b9669.png

El algoritmo de armonización de colores examina el tono del color sin modificar y el color con el que se debe armonizar para ubicar un tono que sea armonioso, pero que no altere sus cualidades de color subyacentes. En el primer gráfico, se representan tonos verdes, amarillos y naranjas menos armoniosos en un espectro. En el siguiente gráfico, el verde y el naranja se armonizaron con el tono amarillo. El nuevo verde es más cálido y el nuevo naranja es más frío.

El tono cambió en el naranja y el verde, pero aún se pueden percibir como naranja y verde.

766516c321348a7c.png

Si quieres obtener más información sobre algunas de las decisiones, exploraciones y consideraciones de diseño, mis colegas Ayan Daniels y Andrew Lu escribieron una entrada de blog que profundiza un poco más que esta sección.

6. Cómo armonizar un color de forma manual

Para armonizar un solo tono, hay dos funciones en MaterialColors, harmonize y harmonizeWithPrimary.

harmonizeWithPrimary usa Context como medio para acceder al tema actual y, luego, al color principal de este.

@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);
  }

Para recuperar el conjunto de cuatro tonos, necesitamos hacer un poco más.

Dado que ya tenemos el color de origen, debemos hacer lo siguiente:

  1. determinar si se debe armonizar,
  2. determinar si estamos en modo oscuro
  3. Devuelve un objeto ColorRoles armonizado o no armonizado.

Cómo determinar si se debe armonizar

En el tema exportado de Material Theme Builder, incluimos atributos booleanos con la nomenclatura harmonize<Color>. A continuación, se muestra una función de conveniencia para acceder a ese valor.

Si se encuentra, devuelve su valor; de lo contrario, determina que no se debe armonizar el 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
}

Cómo crear un objeto ColorRoles armonizado

retrieveHarmonizedColorRoles es otra función de conveniencia que une todos los pasos mencionados anteriormente: recupera el valor de color para un recurso con nombre, intenta resolver un atributo booleano para determinar la armonización y devuelve un objeto ColorRoles derivado del color original o combinado (dado el esquema claro u oscuro).

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. Cómo agregar tarjetas de transporte público

Como mencionamos antes, usaremos un RecyclerView y un adaptador para completar y colorear la colección de tarjetas de transporte.

e4555089b065b5a7.png

Almacenamiento de datos de tránsito

Para almacenar los datos de texto y la información de color de las tarjetas de transporte público, usamos una clase de datos que almacena el nombre, el destino y el ID de recurso de color.

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)
)

Usaremos este color para generar los tonos que necesitamos en tiempo real.

Podrías armonizar en el tiempo de ejecución con la siguiente función 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. Armonizar colores automáticamente

Como alternativa al manejo manual de la armonización, puedes dejar que se maneje por ti. HarmonizedColorOptions es una clase de compilador que te permite especificar gran parte de lo que hicimos manualmente hasta ahora.

Después de recuperar el contexto actual para tener acceso al esquema dinámico actual, debes especificar los colores base que deseas armonizar y crear un contexto nuevo basado en ese objeto HarmonizedColorOptions y en el contexto habilitado de DynamicColors.

Si no quieres armonizar un color, simplemente no lo incluyas en harmonizedOptions.

val newContext = DynamicColors.wrapContextIfAvailable(requireContext())


val harmonizedOptions = HarmonizedColorsOptions.Builder()
 .setColorResourceIds(intArrayOf(R.color.custom1, R.color.custom2))
 .build();

harmonizedContext =
 HarmonizedColors.wrapContextIfAvailable(dynamicColorsContext, harmonizedOptions)

Con el color base armonizado ya controlado, podrías actualizar tu onBindViewHolder para que simplemente llame a MaterialColors.getColorRoles y especifique si los roles devueltos deben ser claros u oscuros.

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. Armonización automática de atributos de temas

Los métodos que se mostraron hasta ahora se basan en recuperar los roles de color de un color individual. Esto es ideal para mostrar que se está generando un tono real, pero no es realista para la mayoría de las aplicaciones existentes. Es probable que no derives un color directamente, sino que uses un atributo de tema existente.

Anteriormente en este codelab, hablamos sobre la exportación de atributos de temas.

<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>

De manera similar al primer método automático, podemos proporcionar valores a HarmonizedColorOptions y usar HarmonizedColors para recuperar un Context con los colores armonizados. Hay una diferencia clave entre los dos métodos. Además, debemos proporcionar una superposición de tema que contenga los campos que se armonizarán.

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)

Tu adaptador usaría el contexto armonizado. Los valores de la superposición del tema deben hacer referencia a la variante clara u oscura sin armonizar.

<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>

Dentro del archivo de diseño XML, podemos usar esos atributos armonizados de forma normal.

<?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. Código fuente

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. Ejemplos de IU

Colores personalizados y temas predeterminados sin armonización

a5a02a72aef30529.png

Colores personalizados armonizados

4ac88011173d6753.png d5084780d2c6b886.png

dd0c8b90eccd8bef.png c51f8a677b22cd54.png

12. Resumen

En este codelab, aprendiste lo siguiente:

  • Conceptos básicos de nuestro algoritmo de armonización de colores
  • Cómo generar roles de color a partir de un color determinado.
  • Cómo armonizar de forma selectiva un color en una interfaz de usuario
  • Cómo armonizar un conjunto de atributos en un tema

Si tienes alguna pregunta, no dudes en consultarnos en cualquier momento en @MaterialDesign en Twitter.

Mira más instructivos y contenido de diseño en youtube.com/MaterialDesign.