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.
🚀 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.
🎯 ¿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ística | Jetpack Compose + Material 3 | XML 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 |
🛠️ 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.
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:
DarkColorSchemeyLightColorScheme: ObjetosColorSchemeque 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 unColorSchemebasado 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éColorSchemeusar basándose endarkThemeydynamicColor.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.
🔄 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:
setContentes donde se define la jerarquía de UI de Compose.useDarkThemeyuseDynamicColorsonmutableStateOfque 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 unSwitchpara 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()yapply(): Guardan el estado delSwitchen lasSharedPreferencescada vez que cambia.
✨ 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.
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?
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
ViewModelpara gestionar eluseDarkThemeyuseDynamicColorpuede 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!
Tutoriales relacionados
- ¡Ponte al Día con Compose! Construyendo Temas Dinámicos en Android con Material 3 y Jetpack Composeintermediate20 min
- Optimización del Rendimiento en Android: Eliminando AnR y Mejorando la Fluidez de tu Appintermediate18 min
- ¡Dominando las Animaciones! Lottie y MotionLayout en Android Nativo para Interfaces Dinámicasintermediate25 min
- ¡Poder sin límites! Integrando APIs de Hardware Android: Sensores, NFC y Bluetooth LEintermediate20 min
- ¡Poder y Flexibilidad! Construyendo Componentes Reutilizables en Android con Jetpack Compose y Modificadores Personalizadosintermediate25 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!