Android: Selección de tema claro y oscuro, pantalla de ajustes

android

Siguiendo la propuesta del tutorial Diseño Android: Tema claro y oscuro con Material Components, tenemos una app de ejemplo diseñada con un tema claro y otro oscuro. De momento la única forma de cambiar de tema es activar el ahorro de batería o el tema oscuro introducido en Android 10, pero las aplicaciones que ofrecen ambos estilos permiten al usuario seleccionar el que quieren utilizar, normalmente con una opción en la típica pantalla de ajustes o configuración.

android selector tema

Nosotros no vamos a ser menos, e implementaremos esta funcionalidad en nuestra app sin complicarnos la vida utilizando directamente los recursos que tenemos disponibles en las librerías de AndroidX.

Estableciendo un tema programáticamente

En primer lugar, veamos cómo configurar programáticamente el tema que queremos aplicar. Esto se hace invocando al método AppCompatDelegate.setDefaultNightMode para indicar el modo “nocturno” mediante un entero definido en esa misma clase con una constante. Vamos a utilizar estas opciones.

  • MODE_NIGHT_NO. Se utilizará siempre el tema claro.
  • MODE_NIGHT_YES. Se utilizará siempre el tema oscuro.
  • MODE_NIGHT_AUTO_BATTERY. Se utiliza el tema claro salvo que se active la opción de ahorro de energía en el dispositivo.
  • MODE_NIGHT_FOLLOW_SYSTEM. En Android 10+, se utiliza el tema claro salvo que se active el modo oscuro o el ahorro de batería. Por lo que he visto en mis pruebas, en estas versiones de Android hay que utilizar esta opción en lugar de MODE_NIGHT_AUTO_BATTERY.

La llamada a setDefaultNightMode aplica el tema de forma inmediata produciendo un cambio de configuración en la app que recrea las activities. Sin embargo, el modo indicado no se persiste y se pierde cuando se abre una nueva instancia de la app. Por tanto, tendremos que guardar el modo en las SharedPreferences y aplicarlo al iniciarse la aplicación. Este guardado lo haremos desde la pantalla de configuración que vamos a crear a continuación.

Pantalla de configuración

AndroidX nos proporciona todo lo necesario para construir pantallas de configuración. Las opciones se definen en un xml y son mostradas por un fragment de tipo PreferenceFragmentCompat el cual utiliza internamente un RecyclerView con el que podemos interactuar para mostrar un separador entre las opciones con el método setDivider o directamente sobrescribiendo onCreateRecyclerView. Pero, tal y como vamos a ver, no tenemos que llegar a esos extremos y apenas vamos a escribir código.

En primer lugar, creamos las opciones que mostrará la pantalla en el fichero /res/xml/app_settings.xml (el nombre realmente no importa). Contamos para ello con varios tipos de widgets.

android studio preferences widgets

También podemos crear widgets personalizados heredando de uno de los ya existentes o bien especializando directamente la clase Preference.

ListPreference ya nos da todo lo que necesitamos así que pasamos a configurarlo del siguiente modo.

<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">

    <androidx.preference.ListPreference
        android:key="@string/settings_theme_key"
        android:title="@string/settings_theme_title"
        android:icon="@drawable/baseline_settings_brightness_24"
        android:dialogTitle="@string/settings_theme_dialog_title"
        android:entries="@array/settings_theme_entries"
        android:entryValues="@array/settings_theme_values"
        android:summary="%s"/>

</androidx.preference.PreferenceScreen>
  • key. La clave con la que se guardará el valor seleccionado en la SharedPreferences. Es un texto que he definido en strings.xml para que sea más fácil utilizarlo con seguridad desde el código.
  • title. El título de la opción. Lo más corto y conciso posible.
  • icon. Icono que se mostrará a la izquierda. Es opcional.
  • dialogTitle. El título del cuadro de diálogo que se abrirá para seleccionar una opción.
  • entries. Array definido en XML con el nombre localizado de cada una de las opciones seleccionables en el cuadro de diálogo. En nuestro ejemplo, tendremos una versión en inglés por defecto (/res/values/array_themes_entries.xml) y otra en español (/res/values-es/array_themes_entries.xml)
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <string-array name="settings_theme_entries">
            <item>Predeterminado del sistema</item>
            <item>Claro</item>
            <item>Oscuro</item>
        </string-array>
    </resources>
    
  • entryValues. El valor con el que la opción seleccionada se guardará en las SharedPreferences. Su orden debe ser coherente con el del array anterior. Para trabajar más cómodamente con estos valores, crearemos un enumerado.
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <string-array name="settings_theme_values">
            <item>DEFAULT</item>
            <item>LIGHT</item>
            <item>DARK</item>
        </string-array>
    </resources>
    
      public enum Mode {
        DEFAULT, DARK, LIGHT
      }
    
  • summary. Es un texto opcional que se muestra debajo del título. Vamos a mostrar la opción seleccionada utilizando el “placeholder” %s para que el widget ya lo haga automáticamente.

En la primera versión del Fragment simplemente indicaremos el fichero con la definición de las opciones y, puesto que si el usuario no ha seleccionado una opción el valor de la opción seleccionada para el ListPreference es null, ponemos el valor que queremos considerar por defecto.

package com.danielme.android.dark.settings;

import android.os.Bundle;

import androidx.preference.ListPreference;
import androidx.preference.PreferenceFragmentCompat;

import com.danielme.android.dark.R;

public class SettingsFragment extends PreferenceFragmentCompat {

  @Override
  public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
    setPreferencesFromResource(R.xml.app_settings, rootKey);

    ListPreference themePreference = getPreferenceManager().findPreference(getString(R.string.settings_theme_key));
    if (themePreference.getValue() == null) {
      themePreference.setValue(ThemeSetup.Mode.DEFAULT.name());
    }
  }

}

El fragment se mostrará dentro de una Activity a la que llegaremos desde la Activity principal (MainActivity) al pulsarse en el menú de la ActionBar. Esta Activity sólo constará de una Toolbar, que nos permitirá volver atrás con el típico botón flecha (navigationIcon), y del fragment que acabamos de crear.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".settings.SettingsActivity">

    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        navigationIcon="?attr/homeAsUpIndicator"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize" />

    <fragment
        android:tag="@string/settings_fragment_tab"
        android:name="com.danielme.android.dark.settings.SettingsFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

En la Activity, suelo crear un método estático para abstraer la llamada a la misma. Esto resulta especialmente práctico si tenemos que pasar parámetros en el Intent, aunque no es el caso.

package com.danielme.android.dark.settings;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;

import com.danielme.android.dark.R;

public class SettingsActivity extends AppCompatActivity {

  public static void start(Context context) {
    Intent intent = new Intent(context, SettingsActivity.class);
    context.startActivity(intent);
  }

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_settings);
    setSupportActionBar(findViewById(R.id.toolbar));
    getSupportActionBar().setDisplayHomeAsUpEnabled(true);
  }

}  

Al declarar la Activity en el manifiesto de la app indicamos el título de la ActionBar\Toolbar y el comportamiento del botón para volver a la activity anterior.

 <activity
   android:name=".settings.SettingsActivity"
   android:label="@string/settings_toolbar" >
   <meta-data android:name="android.support.PARENT_ACTIVITY" 
        android:value=".MainActivity" />
</activity>

El menú ya lo teníamos de la primera parte del tutorial, así que solo tenemos que implementar la navegación en el método onOptionsItemSelected.

  @Override
  public boolean onOptionsItemSelected(@NonNull MenuItem item) {
    SettingsActivity.start(this);
    return true;
  }

Ya podemos llegar hasta la pantalla de ajustes y seleccionar una opción. La selección ya queda persistida automáticamente en las SharedPreferences.

Nuestra app está casi lista, y tan sólo nos falta aplicar lo visto en la sección anterior para que la app utilice el tema correspondiente a la opción que ha seleccionado el usuario (o la opción por defecto si todavía no la ha configurado). Nos apoyaremos en la siguiente clase.

package com.danielme.android.dark.settings;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;

import androidx.appcompat.app.AppCompatDelegate;
import androidx.preference.PreferenceManager;

import com.danielme.android.dark.R;

public final class ThemeSetup {

  private ThemeSetup() {
  }

  public enum Mode {
    DEFAULT, DARK, LIGHT
  }

  public static void applyTheme(Mode mode) {
    switch (mode) {
      case DARK:
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
        break;
      case LIGHT:
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
        break;
      default:
        if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
          AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);
        } else {
          AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
        }
    }
  }

  public static void applyTheme(Context context) {
    SharedPreferences defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
    String value = defaultSharedPreferences.getString(context.getString(R.string.settings_theme_key), Mode.DEFAULT.name());
    applyTheme(Mode.valueOf(value));
  }

}

Obsérvese que se ha tenido en cuenta la versión de Android en la que se ejecuta la app para aplicar el comportamiento por defecto del sistema.

Ahora, al seleccionarse una opción vamos a invocar al método applyTheme para que se aplique inmediatamente. El evento de selección lo capturamos implementando un OnPreferenceChangeListener en el ListPreference, por ejemplo con una lambda. Hay que tener en cuenta que este listener se invoca ANTES de que la opción se haya guardado en la SharedPreferences.

  @Override
  public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
    setPreferencesFromResource(R.xml.app_settings, rootKey);

    ListPreference themePreference = getPreferenceManager().findPreference(getString(R.string.settings_theme_key));
    if (themePreference.getValue() == null) {
      themePreference.setValue(ThemeSetup.Mode.DEFAULT.name());
    }
    themePreference.setOnPreferenceChangeListener((preference, newValue) -> {
      ThemeSetup.applyTheme(ThemeSetup.Mode.valueOf((String) newValue));
      return true;
    });
  }

También hay que establecer el tema cada vez que se arranca la aplicación. Este tipo de tareas se realizan especializando la clase Application.

package com.danielme.android.dark;

import android.app.Application;

import com.danielme.android.dark.settings.ThemeSetup;

public class LightDarkApplication extends Application {

  @Override
  public void onCreate() {
    super.onCreate();
    ThemeSetup.applyTheme(this);
  }

}

La añadimos al manifiesto.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.danielme.android.dark">

    <application
        android:name=".LightDarkApplication"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme.LightDark">

El siguiente video muestra el resultado final en un emulador para Android 11.

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.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios .