Sin duda alguna, los temas oscuros (colores claros y poco saturados sobre fondos muy oscuros) están pegando fuerte en el mundo del software en general, y en el de las aplicaciones móviles en particular. Aunque muchas aplicaciones ya utilizaban este tipo de diseño desde hace años como Spotify, a nivel de sistema operativo Apple abrió camino en 2018 con el lanzamiento de Mojave y le han seguido iOS (13), Android (10) y Windows.
Más allá de los gustos personales, lo cierto es que el elevado contraste del modo oscuro hace más visible el contenido mostrado por la aplicación en condiciones de poca luminosidad, lo que «parece» -hay muchas discusiones al respecto- causar una menor fatiga visual. Asimismo, este menor brillo reduce el consumo de batería en los dispositivos portátiles que cuenten con una pantalla OLED porque los píxeles en negro directamente se apagan. De hecho, el modo de ahorro de energía de Android 10+ también activa el modo oscuro.
El modo oscuro de Android 10, disponible también en Android 9 en las opciones del desarrollador, aplica un estilo oscuro a todas las apps y hace un trabajo más que aceptable adaptando la paleta de colores, aunque he notado pequeñas diferencias en los colores aplicados por Android «puro» en el emulador y por MIUI 12 de Xiaomi. Supongo que otros fabricantes también habrá diferencias.
Este comportamiento se puede configurar con la propiedad forceDarkAllowed que definiremos en el tema principal de la aplicación. De forma predeterminada, su valor es true si nuestro tema hereda de un tema claro, y false si lo hacemos de un tema oscuro o de tipo DayNight que, como veremos en breve, ya aplica a los widgets de nuestra app un tema claro u oscuro según se solicite. Por tanto, si solo tenemos un tema claro y no queremos que Android pueda forzar un tema oscuro porque, por ejemplo, hemos comprobado que queda mal, estableceremos forceDarkAllowed a false.
<style name="AppTheme" parent="Theme.MaterialComponents.Light"> <item name="android:forceDarkAllowed" tools:targetApi="q">false</item> <item name="colorPrimary">@color/color_primary</item> ...
Algunas aplicaciones de referencia -WhatsApp, Telegram, apps de Google- ofrecen al usuario los dos temas y le están acostumbrando a esta posibilidad. Nosotros no vamos a ser menos y, con la inestimable ayuda de las librerías Material Components for Android, en este tutorial vamos a ver los fundamentos necesarios para ponernos manos a la obra. Lo haremos desde un punto de vista meramente técnico, esto es, en lo que respecta al programador y no al diseño gráfico.
En la segunda parte del tutorial, construiremos una pantalla de configuración para que el usuario pueda elegir el tema que desee.
App de ejemplo con tema DayNight
Vamos a hacer una app muy sencilla con la que poder «jugar». Tendrá una única pantalla con una ActionBar implementada con una Toolbar, un campo de entrada de texto de tipo TextInputLayout, un Switch y una BottomAppBar con un FAB (Floating Action Buttom). Todos estos componentes se van presentar dentro de un ConstraintLayout.
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.google.android.material.textfield.TextInputLayout android:id="@+id/text_input_layout_email" style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/activity_horizontal_margin" android:layout_marginTop="@dimen/activity_vertical_margin" android:layout_marginEnd="@dimen/activity_horizontal_margin" android:hint="@string/field_email" app:layout_constraintTop_toBottomOf="@+id/toolbar"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/editTextEmail" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textEmailAddress" /> </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/switch_notifications" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/activity_vertical_margin" android:text="@string/switch_notification" app:layout_constraintEnd_toEndOf="@+id/text_input_layout_email" app:layout_constraintStart_toStartOf="@+id/text_input_layout_email" app:layout_constraintTop_toBottomOf="@+id/text_input_layout_email" /> <Button android:layout_width="0dp" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="@dimen/activity_vertical_margin" android:onClick="validate" android:text="@string/button_validate" app:layout_constraintEnd_toEndOf="@+id/text_input_layout_email" app:layout_constraintStart_toStartOf="@+id/text_input_layout_email" app:layout_constraintTop_toBottomOf="@+id/switch_notifications" /> <androidx.coordinatorlayout.widget.CoordinatorLayout android:id="@+id/coordinator" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.bottomappbar.BottomAppBar android:id="@+id/bottomBar" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" app:fabAlignmentMode="center" app:menu="@menu/bottom_app_bar_menu" app:navigationIcon="@drawable/ic_menu"> </com.google.android.material.bottomappbar.BottomAppBar> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_anchor="@id/bottomBar" android:contentDescription="@string/fab_add" app:srcCompat="@drawable/ic_add" /> </androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.constraintlayout.widget.ConstraintLayout>
En la Activity correspondiente se configura el layout, la Actionbar, el menú, y la lógica del botón. Su código no es relevante para este tutorial.
package com.danielme.android.dark; import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; public class MainActivity extends AppCompatActivity { private TextInputEditText editTextEmail; private TextInputLayout textInputEmail; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); editTextEmail = findViewById(R.id.editTextEmail); textInputEmail = findViewById(R.id.text_input_layout_email); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.toolbar_menu, menu); return super.onCreateOptionsMenu(menu); } public void validate(View view) { if (TextUtils.isEmpty(editTextEmail.getText())) { textInputEmail.setError(getString(R.string.field_mandatory)); textInputEmail.setErrorEnabled(true); } else { textInputEmail.setError(null); textInputEmail.setErrorEnabled(false); } clearFocus(); } private void clearFocus() { View view = this.getCurrentFocus(); if (view instanceof EditText) { InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context .INPUT_METHOD_SERVICE); inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0); view.clearFocus(); } } }
Y ahora viene lo más interesante: la configuración del tema principal de la app. Vamos a heredar del tema Theme.MaterialComponents.DayNight.NoActionBar ya que con los temas DayNight se seleccionará automáticamente como tema base de la app un tema de Material Components de tipo Light o Dark según ordene Android o bien configuremos nosotros en la app. Esto último lo veremos con detalle en la segunda parte del tutorial.
<resources> <style name="AppTheme.LightDark" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <item name="colorPrimary">@color/purple_500</item> <item name="colorPrimaryVariant">@color/purple_900</item> <item name="colorSecondary">@color/red_500</item> <item name="android:statusBarColor">?attr/colorPrimaryVariant</item> <item name="colorOnPrimary">@color/white_50</item> <item name="colorOnSecondary">@color/white_50</item> <item name="colorError">@color/red_300</item> <item name="bottomAppBarStyle">@style/Widget.MaterialComponents.BottomAppBar.PrimarySurface</item> <!-- toolbar --> <item name="toolbarStyle">@style/Widget.MaterialComponents.Toolbar.PrimarySurface</item> <item name="actionOverflowButtonStyle">@style/ToolbarStyle.Overflow</item> <item name="toolbarNavigationButtonStyle">@style/Toolbar.Button.Navigation.Tinted</item> <!-- menús --> <item name="popupTheme">@style/Theme.MaterialComponents.DayNight</item> </style> <style name="ToolbarStyle.Overflow" parent="Widget.AppCompat.ActionButton.Overflow"> <item name="android:tint">@color/white_50</item> </style> <style name="Toolbar.Button.Navigation.Tinted" parent="Widget.AppCompat.Toolbar.Button.Navigation"> <item name="tint">@color/white_50</item> </style> </resources>
Indicaremos en el manifiesto de la app que AppTheme.LightDark será el tema base.
<application android:name=".LightDarkApplication" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme.LightDark">
Para el tema se han personalizado algunos de los 12 tipos de colores definimos actualmente por Material Design y utilizados por Material Components y que además modifican y amplían los colores originalmente disponibles en los estilos de tipo AppCompat. Los más relevantes son los siguientes.
- primaryColor: es el color principal de nuestra marca o «branding» y por tanto el distintivo de la app.
- primaryVariant: versión del color principal generalmente más oscura.
- colorSurface: color aplicado a diversas superficies (una especie área con cierta elevación sobre el fondo) y elementos tales como los menú inferiores Bottom Sheet o las tarjetas. Con los temas de tipo DayNight ya toma por omisión un color claro u oscuro según corresponda.
- colorSecondary: equivale al antiguo colorAccent de Material Design y es un color complementario al primary que se utiliza para destacar o «acentuar» ciertos elementos.
- colorOnSecondary: es el color de los elementos que aparecen sobre los widgets coloreados con colorSecondary. Todos los colores que tienen en su nombre «on» tienen este comportamiento (colorOnPrimary, colorOnSurface…).
- colorError: no necesita descripción, pues es lo que parece. En el ejemplo se aplicará al mensaje de error asociado a la entrada de texto.
Los colores los definimos en el fichero /res/values/colors.xml (realmente el nombre da igual) siguiendo una estrategia de nombrado semántico de tal modo que podamos identificar por su nombre claramente cada color y sus posibles variaciones. Esto facilitará enormemente la definición en la app de temas duales, pues la principal diferencia entre el tema claro y el oscuro será casi exclusivamente la gama de colores aplicada en cada caso.
Para conseguirlo, vamos a seguir la praxis que presenta Google en sus apps de ejemplos y tutoriales para la definición de los colores consistente en nombrar el color que consideremos como «base» o «puro» con el sufijo 500. Esto es, el rojo que elijamos se llamará red_500. A partir de ahí, creamos una paleta de rojos de tal modo que el número del sufijo aumenta a medida que oscurecemos el color base, y disminuye si hacemos lo contrario y nos movemos hacia tonos menos saturados que además son los que se suelen utilizar en los temas oscuros. Esta categorización de los colores es fácil de entender viendo la siguiente imagen que he copiado de la herramienta online gratuita Color Tool.
Para generar la paleta completa de un color, recomiendo utilizar una herramienta online como esta. En cualquier caso, no tenemos que incluir en nuestra app toda la paleta sino los colores que vayamos a usar.
<?xml version="1.0" encoding="utf-8"?> <resources> <item name="purple_500" type="color">#9a28a1</item> <item name="purple_900" type="color">#6c0f74</item> <item name="purple_300" type="color">#b869bd</item> <item name="red_500" type="color">#af1541</item> <item name="red_300" type="color">#c75b7a</item> <color name="white_50">#ffffff</color> <color name="black_900">#000000</color> <color name="grey_900">#252525</color> <color name="cyan_500">#00bcd4</color> </resources>
Ahora ejecutemos el proyecto.
Tenemos que
- La Toolbar tiene colorPrimary porque el tema Widget.MaterialComponents.BottomAppBar.PrimarySurface aplica colorPrimary en modo claro y colorSurface en modo oscuro. Esta configuración se ha establecido para todas las toolbar de la app (propiedad toolbarStyle). Otras opciones de nombre bastante descriptivo son Widget.MaterialComponents.Toolbar.Primary y Widget.MaterialComponents.Toolbar.Surface. Para que los iconos del menú y el botón de navegación hacia atrás se muestren en blanco he utilizado las propiedades actionOverflowButtonStyle y toolbarNavigationButtonStyle.
- El botón y el campo de texto se colorean de forma automática con colorPrimary. El texto del botón toma el valor colorOnPrimary
- El FAB es de color secundario y su icono colorOnSecondary
- El switch no seleccionado usa colorSurface, y seleccionado colorSecondary.
- Para la BottomBar se aplica el tema @style/Widget.MaterialComponents.BottomAppBar.PrimarySurface equivalente al que hemos visto para la Toolbar. Nuevamente se vuelve a aplicar el mismo estilo por omisión para todas las BottomBar de la app (propiedad bottomAppBarStyle del tema).
- Con el estilo Theme.MaterialComponents.DayNight el menú «overflow» se muestra con colorSurface.
Configurar el tema oscuro
¿Y si forzamos el modo oscuro o el ahorro de batería en Android 10+?
El resultado tiene buena pinta y es el esperado para el tema y colores que hemos creado. La toolbar, su menú y BottomBar ahora toman colorSurface, que no hemos personalizado, y que en el caso de la BottomBar se muestra en un tono más claro, mientras el campo de texto y el botón siguen con colorPrimary. Asimismo, el fondo de la aplicación es ahora negro (viene dado por colorBackground).
Tenemos que adaptar, al menos, el color de la status bar. Por fortuna podemos definir recursos, entendiendo como tales colores, estilos, drawable, etc, específicos sólo para el modo oscuro usando el cuantificador night en el nombre de las carpetas que los contienen (values-night, drawables-night, etc). Estos recursos específicos sobrescriben a sus equivalentes predeterminados, esto es, los que se aplican en el tema claro y se encuentran en las carpetas sin el cuantificador night. De este modo, es relativamente sencillo afinar nuestro tema oscuro.
Esto se puede hacer de varias maneras. La estrategia que muestran los videos y ejemplos oficiales de Google consiste básicamente en separar el tema principal de la app en tres: un tema base común a todos los temas y los específicos para el tema claro y el oscuro.
Definamos en el fichero de temas «por omisión» (/res/values), tanto los temas y estilos generales como los específicos para el modo light.
<resources> <!-- Tema base con estilos comunes para light y dark. El tema de cada tipo debe heredar de este--> <style name="Base.AppTheme.LightDark" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <item name="android:forceDarkAllowed" tools:targetApi="q">false</item> <item name="colorOnPrimary">@color/white_50</item> <item name="colorOnSecondary">@color/white_50</item> <item name="colorError">@color/red_300</item> <item name="bottomAppBarStyle">@style/Widget.MaterialComponents.BottomAppBar.PrimarySurface</item> <!-- toolbar --> <item name="toolbarStyle">@style/Widget.MaterialComponents.Toolbar.PrimarySurface</item> <item name="actionOverflowButtonStyle">@style/ToolbarStyle.Overflow</item> <item name="toolbarNavigationButtonStyle">@style/Toolbar.Button.Navigation.Tinted</item> <!-- menús --> <item name="popupTheme">@style/Theme.MaterialComponents.DayNight</item> </style> <style name="ToolbarStyle.Overflow" parent="Widget.AppCompat.ActionButton.Overflow"> <item name="android:tint">@color/white_50</item> </style> <style name="Toolbar.Button.Navigation.Tinted" parent="Widget.AppCompat.Toolbar.Button.Navigation"> <item name="tint">@color/white_50</item> </style> <!-- Configuración específica para light--> <style name="AppTheme.LightDark" parent="Base.AppTheme.LightDark"> <item name="colorPrimary">@color/purple_500</item> <item name="colorPrimaryVariant">@color/purple_900</item> <item name="colorSecondary">@color/red_500</item> <item name="android:statusBarColor">?attr/colorPrimaryVariant</item> </style> </resources>
Y ahora creamos la versión dark en el fichero para night. Lo que hacemos es sobrescribir todos los temas y estilos que queramos utilizando el mismo nombre y que en nuestro ejemplo sólo es uno (AppTheme.LightDark). Insisto, esto que hacemos para el tema puede hacerse de forma análoga para colores, imágenes, drawables…cualquier elemento en las carpetas dentro /res.
<?xml version="1.0" encoding="utf-8"?> <resources> <!-- configuración específica para dark --> <style name="AppTheme.LightDark" parent="Base.AppTheme.LightDark"> <item name="colorPrimary">@color/purple_300</item> <item name="colorPrimaryVariant">@color/purple_500</item> <item name="colorSecondary">@color/cyan_500</item> <item name="colorSurface">@color/grey_900</item> <item name="android:statusBarColor">@color/black_900</item> </style> </resources>
Podemos ver cómo se aplica el nuevo colorPrimary (campo de texto, botón) y colorSecondary (el FAB y el switch). También se ha personalizado colorSurface (Toolbar, BottomBar y menú).
El siguiente video muestra el ejemplo completo incluyendo la selección de temas de la segunda parte del tutorial.
Código de ejemplo
El proyecto completo se encuentra disponible en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.