tutoriales.com

Desarrollo de Temas Oscuros Dinámicos en Android: Un Enfoque Moderno con Jetpack Compose y Material 3

Este tutorial te guiará a través de la implementación de un tema oscuro dinámico en tus aplicaciones Android utilizando las últimas herramientas de Jetpack Compose y Material 3. Mejorarás la experiencia de usuario y la accesibilidad de tu app con una interfaz que se adapta automáticamente.

Intermedio15 min de lectura7 views
Reportar error

🚀 Introducción al Modo Oscuro en Android

El modo oscuro ha pasado de ser una simple característica a una expectativa fundamental del usuario. Ofrece múltiples beneficios, como la reducción de la fatiga visual en entornos con poca luz, el ahorro de batería en dispositivos con pantallas OLED y una experiencia de usuario personalizable. Como desarrolladores Android, es crucial ofrecer esta opción para mantener nuestras aplicaciones modernas y accesibles.

En este tutorial, exploraremos cómo integrar el modo oscuro de manera efectiva en tus aplicaciones Android utilizando las herramientas más recientes y recomendadas: Jetpack Compose y Material 3. Nos centraremos en la creación de un tema oscuro dinámico que se adapte a las preferencias del sistema o del usuario, elevando la calidad visual de tu aplicación.

💡 **Consejo:** Ofrecer un modo oscuro no es solo estético, sino que mejora significativamente la accesibilidad y la sostenibilidad de tu aplicación.

🎯 ¿Por qué Jetpack Compose y Material 3?

Jetpack Compose es el kit de herramientas de UI moderno de Android, que simplifica enormemente la construcción de interfaces de usuario. Su enfoque declarativo facilita la creación de UIs adaptables y reactivas. Material Design 3, la última evolución del sistema de diseño de Google, está diseñado para trabajar mano a mano con Compose, ofreciendo un conjunto de componentes y un sistema de tematización mucho más flexible y potente, especialmente para el modo oscuro y los temas dinámicos.

Con Material 3, obtendremos:

  • Paletas de colores dinámicas: Adaptación automática de colores basada en el fondo de pantalla del usuario (conocido como Dynamic Color o Monet).
  • Componentes actualizados: Elementos de UI con un aspecto y comportamiento modernos.
  • Tipografía y formas: Un sistema coherente para aplicar estilos a tu aplicación.

Ventajas de Usar Compose y Material 3

CaracterísticaJetpack Compose + Material 3XML tradicional + Material 2
---------
Declarativo✅ Sí❌ No
Reactivo✅ Sí⚠️ Parcialmente
---------
Rendimiento✨ Generalmente mejor⏳ Puede ser más lento
Modularidad✅ Excelente👍 Bueno
---------
Tema Oscuro🚀 Muy optimizado🛠️ Requiere más esfuerzo
Dynamic Color✅ Nativo❌ No soportado directamente
🔥 **Importante:** La adopción de Compose y Material 3 es una inversión en el futuro de tus aplicaciones Android, proporcionando una base sólida para el desarrollo moderno.

🛠️ Configuración Inicial del Proyecto

Antes de sumergirnos en el código, asegúrate de tener tu entorno de desarrollo listo.

1. Requisitos Previos

  • Android Studio Dolphin (o superior): Necesario para un soporte completo de Compose y Material 3.
  • Kotlin: Fundamento del desarrollo con Compose.
  • SDK de Android 21 o superior: Recomendado para Material 3.

2. Creación de un Nuevo Proyecto

Si aún no tienes un proyecto, crea uno nuevo en Android Studio. Selecciona la plantilla Empty Activity para Jetpack Compose.

3. Dependencias de Gradle

Abre tu archivo build.gradle.kts (Module: app) y asegúrate de tener las siguientes dependencias. Es posible que algunas ya estén incluidas si usaste la plantilla de Compose:

// build.gradle.kts (Module: app)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.example.darkmodetutorial"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.example.darkmodetutorial"
        minSdk = 21
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.1"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:2023.08.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.activity:activity-compose:1.8.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
    // Para el Dynamic Color (si lo quieres usar)
    implementation("com.google.android.material:material:1.10.0") // Puede que sea necesaria para Dynamic Color utilidades

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

Asegúrate de reemplazar compose-bom y kotlinCompilerExtensionVersion con las últimas versiones estables. Sincroniza tu proyecto Gradle después de hacer cambios.


🎨 Definiendo Temas y Colores con Material 3

Material 3 introduce un sistema de tematización más robusto y flexible, basado en un sistema de roles de color en lugar de simplemente primary, secondary, etc. Esto facilita la adaptación a diferentes modos (claro/oscuro) y a la personalización de Dynamic Color.

1. Colores en Color.kt

En tu proyecto, navega a ui.theme/Color.kt. Aquí es donde definiremos nuestras paletas de colores para el tema claro y oscuro.

// app/src/main/java/com/example/darkmodetutorial/ui/theme/Color.kt

package com.example.darkmodetutorial.ui.theme

import androidx.compose.ui.graphics.Color

val md_theme_light_primary = Color(0xFF6750A4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFEADDFF)
val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
val md_theme_light_secondary = Color(0xFF625B71)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
val md_theme_light_tertiary = Color(0xFF7D5260)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4)
val md_theme_light_onTertiaryContainer = Color(0xFF31111D)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1C1B1F)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1C1B1F)
val md_theme_light_surfaceVariant = Color(0xFFE7E0EC)
val md_theme_light_onSurfaceVariant = Color(0xFF49454F)
val md_theme_light_outline = Color(0xFF7A757F)
val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4)
val md_theme_light_inverseSurface = Color(0xFF313034)
val md_theme_light_inversePrimary = Color(0xFFD0BCFF)
val md_theme_light_surfaceTint = Color(0xFF6750A4)
val md_theme_light_outlineVariant = Color(0xFFCAC4D0)
val md_theme_light_scrim = Color(0xFF000000)

val md_theme_dark_primary = Color(0xFFD0BCFF)
val md_theme_dark_onPrimary = Color(0xFF381E72)
val md_theme_dark_primaryContainer = Color(0xFF4F378B)
val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF)
val md_theme_dark_secondary = Color(0xFFCCC2DC)
val md_theme_dark_onSecondary = Color(0xFF332D41)
val md_theme_dark_secondaryContainer = Color(0xFF4A4458)
val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8)
val md_theme_dark_tertiary = Color(0xFFEFB8C8)
val md_theme_dark_onTertiary = Color(0xFF492532)
val md_theme_dark_tertiaryContainer = Color(0xFF633B48)
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1C1B1F)
val md_theme_dark_onBackground = Color(0xFFE6E1E5)
val md_theme_dark_surface = Color(0xFF1C1B1F)
val md_theme_dark_onSurface = Color(0xFFE6E1E5)
val md_theme_dark_surfaceVariant = Color(0xFF49454F)
val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0)
val md_theme_dark_outline = Color(0xFF948F99)
val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F)
val md_theme_dark_inverseSurface = Color(0xFFE6E1E5)
val md_theme_dark_inversePrimary = Color(0xFF6750A4)
val md_theme_dark_surfaceTint = Color(0xFFD0BCFF)
val md_theme_dark_outlineVariant = Color(0xFF49454F)
val md_theme_dark_scrim = Color(0xFF000000)


val seed = Color(0xFF6750A4)
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)

val Purple40 = Color(0xFF6750A4)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)

Estos colores se generan típicamente a partir de un color semilla utilizando la herramienta de Material Theme Builder de Google. Es una excelente manera de asegurar la coherencia del diseño.

📌 **Nota:** Puedes usar la herramienta Material Theme Builder (m3.material.io/theme-builder) para generar automáticamente estas paletas de colores basadas en un color principal de tu elección.

2. Definiendo el Tema en Theme.kt

El archivo Theme.kt (dentro de ui.theme) es donde se define la lógica de tu tema. Aquí crearemos el DarkModetutorialTheme que contendrá la lógica para cambiar entre el tema claro y oscuro y, opcionalmente, para aplicar Dynamic Color.

// app/src/main/java/com/example/darkmodetutorial/ui/theme/Theme.kt

package com.example.darkmodetutorial.ui.theme

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

private val DarkColorScheme = darkColorScheme(
    primary = md_theme_dark_primary,
    onPrimary = md_theme_dark_onPrimary,
    primaryContainer = md_theme_dark_primaryContainer,
    onPrimaryContainer = md_theme_dark_onPrimaryContainer,
    secondary = md_theme_dark_secondary,
    onSecondary = md_theme_dark_onSecondary,
    secondaryContainer = md_theme_dark_secondaryContainer,
    onSecondaryContainer = md_theme_dark_onSecondaryContainer,
    tertiary = md_theme_dark_tertiary,
    onTertiary = md_theme_dark_onTertiary,
    tertiaryContainer = md_theme_dark_tertiaryContainer,
    onTertiaryContainer = md_theme_dark_onTertiaryContainer,
    error = md_theme_dark_error,
    onError = md_theme_dark_onError,
    errorContainer = md_theme_dark_errorContainer,
    onErrorContainer = md_theme_dark_onErrorContainer,
    background = md_theme_dark_background,
    onBackground = md_theme_dark_onBackground,
    surface = md_theme_dark_surface,
    onSurface = md_theme_dark_onSurface,
    surfaceVariant = md_theme_dark_surfaceVariant,
    onSurfaceVariant = md_theme_dark_onSurfaceVariant,
    outline = md_theme_dark_outline,
    inverseOnSurface = md_theme_dark_inverseOnSurface,
    inverseSurface = md_theme_dark_inverseSurface,
    inversePrimary = md_theme_dark_inversePrimary,
    surfaceTint = md_theme_dark_surfaceTint,
    outlineVariant = md_theme_dark_outlineVariant,
    scrim = md_theme_dark_scrim,
)

private val LightColorScheme = lightColorScheme(
    primary = md_theme_light_primary,
    onPrimary = md_theme_light_onPrimary,
    primaryContainer = md_theme_light_primaryContainer,
    onPrimaryContainer = md_theme_light_onPrimaryContainer,
    secondary = md_theme_light_secondary,
    onSecondary = md_theme_light_onSecondary,
    secondaryContainer = md_theme_light_secondaryContainer,
    onSecondaryContainer = md_theme_light_onSecondaryContainer,
    tertiary = md_theme_light_tertiary,
    onTertiary = md_theme_light_onTertiary,
    tertiaryContainer = md_theme_light_tertiaryContainer,
    onTertiaryContainer = md_theme_light_onTertiaryContainer,
    error = md_theme_light_error,
    onError = md_theme_light_onError,
    errorContainer = md_theme_light_errorContainer,
    onErrorContainer = md_theme_light_onErrorContainer,
    background = md_theme_light_background,
    onBackground = md_theme_light_onBackground,
    surface = md_theme_light_surface,
    onSurface = md_theme_light_onSurface,
    surfaceVariant = md_theme_light_surfaceVariant,
    onSurfaceVariant = md_theme_light_onSurfaceVariant,
    outline = md_theme_light_outline,
    inverseOnSurface = md_theme_light_inverseOnSurface,
    inverseSurface = md_theme_light_inverseSurface,
    inversePrimary = md_theme_light_inversePrimary,
    surfaceTint = md_theme_light_surfaceTint,
    outlineVariant = md_theme_light_outlineVariant,
    scrim = md_theme_light_scrim,
)

@Composable
fun DarkModetutorialTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available only on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = colorScheme.primary.toArgb()
            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
        }
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

Explicación:

  • DarkColorScheme y LightColorScheme: Objetos ColorScheme que agrupan todos los colores para cada modo. Estos son los colores fallback si Dynamic Color no está disponible.
  • isSystemInDarkTheme(): Esta función de Compose detecta si el sistema operativo está en modo oscuro.
  • dynamicDarkColorScheme() / dynamicLightColorScheme(): Funciones que, en Android 12+ (API 31+), generan un ColorScheme basado en el color del fondo de pantalla del usuario. Esto es lo que permite el Dynamic Color.
  • DarkModetutorialTheme: Nuestro composable principal que envuelve el contenido de la aplicación. Decide qué ColorScheme usar basándose en darkTheme y dynamicColor.
  • SideEffect: Se usa para modificar la barra de estado del sistema (color y tema de iconos) para que coincida con el tema de la aplicación. Esto asegura una experiencia visual cohesente.
⚠️ **Advertencia:** `Dynamic Color` solo funciona en dispositivos Android 12 (API 31) y superiores. Para versiones anteriores, tu aplicación utilizará los `LightColorScheme` y `DarkColorScheme` definidos manualmente.

🔄 Implementando el Cambio de Tema Dinámico

Ahora que tenemos nuestros temas definidos, vamos a aplicarlos a nuestra MainActivity y ver cómo podemos permitir al usuario alternar entre ellos o seguir la preferencia del sistema.

1. Aplicando el Tema en MainActivity.kt

La MainActivity es el punto de entrada de tu aplicación. Aquí es donde se establece el DarkModetutorialTheme.

// app/src/main/java/com/example/darkmodetutorial/MainActivity.kt

package com.example.darkmodetutorial

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.darkmodetutorial.ui.theme.DarkModetutorialTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // Estado para controlar si el tema oscuro está activado
            var useDarkTheme by remember { mutableStateOf(false) }
            // Estado para controlar si Dynamic Color está activado
            var useDynamicColor by remember { mutableStateOf(true) }

            DarkModetutorialTheme(darkTheme = useDarkTheme, dynamicColor = useDynamicColor) {
                // Una superficie usando los colores del tema 'surface' y 'onSurface'
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    HomeScreen(useDarkTheme, onThemeChange = { useDarkTheme = it },
                               useDynamicColor, onDynamicColorChange = { useDynamicColor = it})
                }
            }
        }
    }
}

@Composable
fun HomeScreen(currentDarkTheme: Boolean, onThemeChange: (Boolean) -> Unit,
               currentDynamicColor: Boolean, onDynamicColorChange: (Boolean) -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "¡Bienvenido a tu App con Tema Dinámico!",
            style = MaterialTheme.typography.headlineMedium,
            color = MaterialTheme.colorScheme.onBackground
        )
        Spacer(Modifier.height(32.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text("Modo Oscuro", style = MaterialTheme.typography.bodyLarge)
            Switch(checked = currentDarkTheme, onCheckedChange = onThemeChange)
        }
        Spacer(Modifier.height(16.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text("Dynamic Color (Android 12+)", style = MaterialTheme.typography.bodyLarge)
            Switch(checked = currentDynamicColor, onCheckedChange = onDynamicColorChange)
        }
        Spacer(Modifier.height(32.dp))

        Button(onClick = { /* Hacer algo */ }) {
            Text("Botón de Acción", style = MaterialTheme.typography.bodyLarge)
        }

        Spacer(Modifier.height(16.dp))

        Text(
            text = "Este es un texto de ejemplo para mostrar cómo se adaptan los colores.",
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = Modifier.padding(horizontal = 16.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    DarkModetutorialTheme {
        HomeScreen(false, {}, true, {})
    }
}

@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
fun DarkThemePreview() {
    DarkModetutorialTheme(darkTheme = true) {
        HomeScreen(true, {}, true, {})
    }
}

Observa lo siguiente:

  • setContent es donde se define la jerarquía de UI de Compose.
  • useDarkTheme y useDynamicColor son mutableStateOf que permiten que la UI reaccione a los cambios del usuario.
  • DarkModetutorialTheme(darkTheme = useDarkTheme, dynamicColor = useDynamicColor): Pasamos el estado actual para que el tema se recomponga con los colores correctos.
  • HomeScreen: Un composable de ejemplo que contiene un Switch para alternar el tema oscuro y otro para el Dynamic Color, además de otros componentes para demostrar la aplicación del tema.

2. Guardando la Preferencia del Usuario

Para que la elección del usuario persista entre sesiones, necesitamos almacenar el estado de useDarkTheme y useDynamicColor. Usaremos SharedPreferences para este propósito, aunque podrías considerar Jetpack DataStore para una solución más moderna y asíncrona.

Modificando MainActivity.kt para persistencia:

// app/src/main/java/com/example/darkmodetutorial/MainActivity.kt

package com.example.darkmodetutorial

import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme
// ... otras importaciones ...
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.SideEffect
// ... otras importaciones ...

class MainActivity : ComponentActivity() {

    private val PREFS_NAME = "app_prefs"
    private val KEY_DARK_THEME = "dark_theme"
    private val KEY_DYNAMIC_COLOR = "dynamic_color"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val isSystemDark = isSystemInDarkTheme()
            var useDarkTheme by remember { mutableStateOf(isSystemDark) }
            var useDynamicColor by remember { mutableStateOf(true) }

            // Cargar preferencias al inicio
            LaunchedEffect(Unit) {
                val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
                useDarkTheme = prefs.getBoolean(KEY_DARK_THEME, isSystemDark)
                useDynamicColor = prefs.getBoolean(KEY_DYNAMIC_COLOR, true)
            }

            // Guardar preferencias cuando cambian
            val onThemeChange: (Boolean) -> Unit = {
                useDarkTheme = it
                getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
                    .putBoolean(KEY_DARK_THEME, it)
                    .apply()
            }

            val onDynamicColorChange: (Boolean) -> Unit = {
                useDynamicColor = it
                getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
                    .putBoolean(KEY_DYNAMIC_COLOR, it)
                    .apply()
            }

            DarkModetutorialTheme(darkTheme = useDarkTheme, dynamicColor = useDynamicColor) {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    HomeScreen(useDarkTheme, onThemeChange, useDynamicColor, onDynamicColorChange)
                }
            }
        }
    }
}

// HomeScreen Composable sigue siendo el mismo
// ...

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    DarkModetutorialTheme {
        HomeScreen(false, {}, true, {})
    }
}

@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
fun DarkThemePreview() {
    DarkModetutorialTheme(darkTheme = true) {
        HomeScreen(true, {}, true, {})
    }
}

Detalles de la persistencia:

  • LaunchedEffect(Unit): Se utiliza para ejecutar código una vez cuando el composable entra en la composición, ideal para cargar datos iniciales.
  • getSharedPreferences(): Accede a las preferencias compartidas de la aplicación.
  • putBoolean() y apply(): Guardan el estado del Switch en las SharedPreferences cada vez que cambia.
Inicio Detectar preferencia del sistema/usuario Cargar preferencias guardadas Aplicar Dynamic Color (Si Android 12+ y activado) Aplicar Light/Dark ColorScheme fallback Renderizar UI con MaterialTheme

✨ Personalización y Mejores Prácticas

Para una implementación de modo oscuro verdaderamente pulida, considera los siguientes puntos:

1. Manejo de Imágenes y Iconos

Es común que las imágenes o iconos necesiten versiones diferentes para el modo claro y oscuro. Por ejemplo, un logo que se ve bien sobre un fondo claro podría desaparecer en un fondo oscuro. Android permite especificar recursos alternativos:

  • Drawables: Coloca la versión oscura en res/drawable-night.
  • Imágenes vectoriales: Pueden adaptarse a los colores del tema si sus partes usan MaterialTheme.colorScheme.onSurface, por ejemplo.
💡 **Consejo:** Siempre prueba tus activos visuales en ambos modos para asegurar la legibilidad y el contraste.

2. Pruebas Exhaustivas

Es fundamental probar tu aplicación en ambos modos (claro y oscuro) y con Dynamic Color activado/desactivado en diferentes dispositivos y versiones de Android. Presta atención a:

  • Contraste: ¿Son todos los elementos legibles?
  • Visibilidad: ¿Hay elementos que se pierden en un modo?
  • Coherencia: ¿Se mantiene la identidad visual de tu marca?
  • Transiciones: ¿El cambio de tema es suave o hay parpadeos?
90% Cobertura de Pruebas Recomendada

3. Consideraciones de Accesibilidad

El modo oscuro es una característica de accesibilidad. Asegúrate de cumplir con las pautas de accesibilidad, especialmente en cuanto al contraste de colores. Herramientas como el Accessibility Scanner de Google pueden ayudarte a identificar problemas.

4. Alternativas y Patrones Avanzados

Para aplicaciones más complejas, podrías considerar:

  • Múltiples temas: Permitir al usuario elegir entre varios temas claros y oscuros predefinidos.
  • Tematización por módulo: Si tu aplicación es modular, cada módulo podría definir sus propios colores base que se integren con el tema principal.
  • Integración con ViewModel: Para estados de tema más complejos o compartidos, usar un ViewModel para gestionar el useDarkTheme y useDynamicColor puede ser más limpio.
¿Cómo usar ViewModel para gestionar el tema?

Podrías crear un ThemeViewModel:

class ThemeViewModel(private val prefsRepository: PrefsRepository) : ViewModel() {

    private val _useDarkTheme = MutableStateFlow(false)
    val useDarkTheme: StateFlow<Boolean> = _useDarkTheme.asStateFlow()

    private val _useDynamicColor = MutableStateFlow(true)
    val useDynamicColor: StateFlow<Boolean> = _useDynamicColor.asStateFlow()

    init {
        viewModelScope.launch {
            prefsRepository.darkThemePreference.collect { _useDarkTheme.value = it }
            prefsRepository.dynamicColorPreference.collect { _useDynamicColor.value = it }
        }
    }

    fun toggleDarkTheme(isDark: Boolean) {
        viewModelScope.launch { prefsRepository.saveDarkThemePreference(isDark) }
    }

    fun toggleDynamicColor(isDynamic: Boolean) {
        viewModelScope.launch { prefsRepository.saveDynamicColorPreference(isDynamic) }
    }
}

// PrefsRepository (usando DataStore, por ejemplo)
class PrefsRepository(private val context: Context) {
    private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
    private object PreferencesKeys {
        val DARK_THEME = booleanPreferencesKey("dark_theme")
        val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
    }

    val darkThemePreference: Flow<Boolean> = context.dataStore.data
        .map { preferences ->
            preferences[PreferencesKeys.DARK_THEME] ?: false // Default a false (light theme)
        }

    val dynamicColorPreference: Flow<Boolean> = context.dataStore.data
        .map { preferences ->
            preferences[PreferencesKeys.DYNAMIC_COLOR] ?: true // Default a true
        }

    suspend fun saveDarkThemePreference(isDark: Boolean) {
        context.dataStore.edit {
            it[PreferencesKeys.DARK_THEME] = isDark
        }
    }

    suspend fun saveDynamicColorPreference(isDynamic: Boolean) {
        context.dataStore.edit {
            it[PreferencesKeys.DYNAMIC_COLOR] = isDynamic
        }
    }
}

Esto proporcionaría un estado de tema observable y persistente de una manera más robusta.


✅ Conclusión

La implementación de temas oscuros dinámicos en Android utilizando Jetpack Compose y Material 3 es un paso fundamental para crear aplicaciones modernas, accesibles y visualmente atractivas. Hemos cubierto desde la configuración inicial, la definición de paletas de colores con Material 3, hasta la lógica para cambiar entre temas y la persistencia de las preferencias del usuario.

Al seguir este tutorial, no solo habrás añadido una característica popular a tu aplicación, sino que también habrás adoptado las mejores prácticas y las últimas tecnologías en el desarrollo de UI para Android. ¡Experimenta con diferentes combinaciones de colores y personaliza tu tema para que refleje la identidad única de tu aplicación!

🔥 **Recuerda:** Un buen tema oscuro es aquel que mejora la experiencia del usuario sin sacrificar la legibilidad o la marca de tu aplicación.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!