Ajustes en Android
Al terminar: Montar la pantalla de ajustes en Android: la pestaña, la fila reutilizable y la hoja para elegir el tema.
Las preferencias ya se guardan y nadie puede cambiarlas. Al terminar esta lección Android tendrá una tercera pestaña, «Ajustes», con una pantalla completa: una fila para elegir el tema (que se abre en una hoja inferior), un interruptor para el orden de favoritos y una fila informativa «Acerca de». La pantalla guarda lo que elijas. El orden ya manda en la lista de favoritos desde Ajustes y favoritos para toda la app (lo calcula el código común), así que el interruptor ya tendrá efecto; lo que todavía no existe es el tema aplicado: elegir «Oscuro» no repintará la app. Eso es la lección siguiente.
Las piezas de Compose que ya usaste (Scaffold, Row, Column, Switch, el estado elevado) aquí solo se usan. Lo nuevo es pequeño y cabe en tres ideas: una fila reutilizable con un hueco y una acción opcional, una propiedad que se le añade a un tipo que ya existe, y una elección entre tres valores en una hoja inferior.
Lo nuevo, con un ejemplo ajeno al proyecto
Tres piezas de Kotlin aparecen en esta lección y no se han visto juntas (la segunda ya te sonará: la usaste en la lección anterior). Antes de usarlas en la app, un ejemplo con una caja registradora de tienda, que se puede probar en el Playground de Kotlin (play.kotlinlang.org).
Un parámetro que es una función, y que puede faltar. Un parámetro puede ser una función: () -> Unit se lee «una función que no recibe nada y no devuelve nada». Con un ? detrás, (() -> Unit)?, se lee «una función de esas, o nada»: el ? permite null. Y = null hace que, si quien llama no la pasa, el parámetro valga «nada». Así la misma función sirve para quien quiere una acción y para quien no.
Una propiedad que se añade a un tipo que ya existe. Ya la viste en la lección anterior (insigniaForIos): val Turno.etiqueta escribe una propiedad nueva sobre Turno sin abrir el fichero donde Turno se definió. Es una extensión.
// Un enumerado: un tipo con un número fijo de valores posibles.
enum class Turno { MANANA, TARDE }
// Propiedad de extensión: `Turno` ya existe; aquí le añadimos `etiqueta`.
// `get() =` dice cómo se calcula cada vez que alguien la lee.
// El `when` cubre todos los valores, así que no necesita `else`.
val Turno.etiqueta: String
get() = when (this) {
Turno.MANANA -> "Turno de mañana"
Turno.TARDE -> "Turno de tarde"
}
// `alCobrar` es una función opcional: puede ser una función o `null`.
// `= null`: si quien llama no la pasa, vale `null`.
fun cobrar(producto: String, precio: Int, alCobrar: (() -> Unit)? = null) {
println("Cobrado: $producto, $precio pesos")
if (alCobrar != null) { // solo si nos dieron una función...
alCobrar() // ...se ejecuta
}
}
fun main() {
println(Turno.TARDE.etiqueta)
cobrar("pan", 2000) // sin acción: solo cobra
cobrar("leche", 4500, alCobrar = { println("Sonó la campanita") }) // con acción
}Lo que imprime:
Turno de tarde
Cobrado: pan, 2000 pesos
Cobrado: leche, 4500 pesos
Sonó la campanitaLa tercera pieza es de Compose y se explica donde se usa: un parámetro de tipo @Composable () -> Unit es un hueco que rellena quien llama, igual que decides qué va en topBar de un Scaffold.
Paso 1 · La tercera pestaña, con la pantalla vacía
El archivo. SettingsScreen.kt es la pantalla de la pestaña. Créala con el método de La lista en Android: clic derecho sobre el nodo androidApp → New → File y escribe la ruta desde la carpeta del módulo, src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt (Android Studio crea la carpeta settings que falta). Plan B, desde la Terminal, en la carpeta de tu proyecto (touch no crea carpetas, por eso va antes mkdir -p):
mkdir -p androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings
touch androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.ktEmpieza por el esqueleto: el package, los import de toda la pantalla (los que aún no uses saldrán en gris hasta los pasos 2 y 3) y una pantalla con título y nada más.
package com.sebastiannarvaez.pokedex.android.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.feature.settings.SettingsViewModel
import com.sebastiannarvaez.pokedex.feature.settings.Tema
import org.koin.compose.viewmodel.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) // TopAppBar es experimental en Material 3
@Composable
fun SettingsScreen(modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { TopAppBar(title = { Text("Ajustes", fontWeight = FontWeight.Bold) }) },
) { relleno ->
// Las filas, en el paso 2.
Column(modifier = Modifier.padding(relleno).fillMaxSize()) {}
}
}La clave de navegación. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt y añade la clave (junto a FavoritosKey, con su @Serializable por la misma razón que las otras) y amplía la lista de pestañas:
@Serializable
data object AjustesKey : NavKey/** Las pestañas de primer nivel, que nunca se apilan entre sí. */
val PESTANAS: List<NavKey> = listOf(ListaKey, FavoritosKey, AjustesKey)La barra y la entrada. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt. Son tres cambios, y no se toca nada más:
- Dos
importnuevos, junto a los que ya tiene (también te los propone Android Studio con Option+Enter sobre los nombres en rojo):
import androidx.compose.material.icons.filled.Settings
import com.sebastiannarvaez.pokedex.android.settings.SettingsScreen- Un elemento nuevo en la barra, justo debajo del de Favoritos y dentro del mismo
NavigationBar. Los iconos de Material vienen en variantes:Icons.Defaultes otro nombre deIcons.Filled(el dibujo relleno) yIcons.Outlinedes el mismo dibujo solo con contorno; por eso elimportde arriba termina enfilled.Settings:
NavigationBarItem(
selected = actual == AjustesKey,
onClick = { irAPestana(pila, AjustesKey) },
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Ajustes") },
)- Una entrada en el
entryProvider, debajo deentry<FavoritosKey> { … }:
entry<AjustesKey> { SettingsScreen() }Nada más cambia en PokedexApp.kt: ni irAPestana, ni AnimatedVisibility, ni entryDecorators.
Ejecútala (configuración androidApp, como en Ejecutar en Android). Hay tres pestañas en la barra y la tercera abre una pantalla con el título «Ajustes» y nada debajo.
Paso 2 · Las filas: una fila reutilizable
El concepto. Android no trae un contenedor de ajustes ya hecho, así que la lista se compone a mano. Todas las filas son iguales por dentro (título y detalle a la izquierda, algo a la derecha), así que se escribe una sola fila, Fila, y se usa tres veces. Lo que cambia entre filas (un icono, un Switch) entra como un hueco @Composable; y lo que hace la fila al pulsarla entra como la función opcional del ejemplo de arriba: la fila de «Acerca de» no tiene nada que hacer, así que no la recibe y no es pulsable.
Para decidir si la fila es pulsable se usa .then(…): Modifier.then(otro) añade a la cadena un modificador elegido en ese momento, y Modifier a secas no añade nada.
Sustituye la función SettingsScreen entera por esta (los import del esqueleto ya cubren todo) y añade debajo Fila:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = koinViewModel(), // el de `shared`, pedido a Koin
) {
// Las preferencias como estado de Compose: la pantalla se repinta cuando cambian.
val ajustes by viewModel.settings.collectAsStateWithLifecycle()
var hojaTema by remember { mutableStateOf(false) } // ¿está abierta la hoja del tema? (paso 3)
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { TopAppBar(title = { Text("Ajustes", fontWeight = FontWeight.Bold) }) },
) { relleno ->
Column(modifier = Modifier.padding(relleno).fillMaxSize()) {
Fila(
titulo = "Tema",
detalle = ajustes.tema.name, // provisional: el nombre tal cual
// `Icons.Outlined`: el icono solo con contorno (ver `Icons.Default` en el paso 1).
icono = { Icon(Icons.Outlined.Settings, contentDescription = null) },
alPulsar = { hojaTema = true }, // abrirá la hoja (paso 3)
)
Fila(
titulo = "Ordenar favoritos por número",
detalle = if (ajustes.favoritosPorNumero) "Del 1 al último" else "Lo más reciente primero",
icono = {
Switch(
checked = ajustes.favoritosPorNumero,
onCheckedChange = viewModel::cambiarOrden, // llama al ViewModel con el valor nuevo
)
},
alPulsar = { viewModel.cambiarOrden(!ajustes.favoritosPorNumero) },
)
Fila(
titulo = "Acerca de",
detalle = "Pokédex · datos de PokeAPI",
icono = { Icon(Icons.Default.Info, contentDescription = null) },
// sin `alPulsar`: es una fila informativa
)
}
}
}
// Una fila de ajustes: título y detalle a la izquierda, y a la derecha lo que
// le pases en `icono` (un icono, un Switch...). `private`: solo se usa aquí.
@Composable
private fun Fila(
titulo: String,
detalle: String,
icono: @Composable () -> Unit, // el hueco: quien llama decide qué se dibuja
alPulsar: (() -> Unit)? = null, // la acción es opcional
) {
Row(
modifier = Modifier
.fillMaxWidth()
// `.then(otro)` encadena otro modificador a continuación. Aquí: pulsable
// solo si hay algo que hacer al pulsar; si no, un `Modifier` vacío.
.then(if (alPulsar != null) Modifier.clickable(onClick = alPulsar) else Modifier)
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
// `weight(1f)`: los dos textos ocupan todo el ancho que sobra (`1f` es el número
// 1,0: «una parte», y como la fila no le da parte a nadie más, se queda con el resto).
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(titulo, style = MaterialTheme.typography.bodyLarge)
Text(
detalle,
style = MaterialTheme.typography.bodySmall,
// `onSurfaceVariant`: el gris suave del tema para textos secundarios.
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
icono()
}
}Tres piezas que ya conoces de lecciones anteriores:
Dos detalles de diseño. El detalle describe el efecto, no repite el título: «Lo más reciente primero» dice qué va a ver quien lo lea, mientras que «Desactivado» no dice nada. Y la fila entera es pulsable además del interruptor, porque acertar en una fila entera es más fácil que acertar en el interruptor con el pulgar.
Ejecútala. Pulsa el interruptor: se mueve y el texto cambia a «Del 1 al último». Se guarda, y la lista de Favoritos ya cambia de orden con él (lo calcula el código común de la lección anterior): ciérrala del todo (abre Recientes con el botón cuadrado o el gesto de deslizar hacia arriba desde abajo, y desliza la tarjeta de la app hacia arriba) y ábrela, y el interruptor sigue como lo dejaste. «Tema» sale con el nombre del valor (SISTEMA) y no abre nada aún.
Paso 3 · El tema se elige en una hoja inferior
El concepto. Elegir uno entre tres valores es una elección corta, y la forma de Android de pedirla sin sacar a quien usa la app de la pantalla es una hoja inferior modal, el ModalBottomSheet que ya usaste en Pestañas en Android.
Primero, el nombre legible de cada tema. Es la propiedad de extensión del ejemplo de arriba: añade etiqueta a Tema sin tocar Settings.kt, y va en la app, al final del mismo fichero, y no en shared: el texto es cosa de cada interfaz (iOS escribirá el suyo).
// El nombre legible de cada tema. Vive en la app y no en `shared`: el texto
// es cosa de cada interfaz.
val Tema.etiqueta: String
get() = when (this) {
Tema.SISTEMA -> "Lo que diga el sistema"
Tema.CLARO -> "Claro"
Tema.OSCURO -> "Oscuro"
}Con eso, tres cambios más en SettingsScreen:
- En la fila «Tema», sustituye la línea entera
detalle = ajustes.tema.name,, con su comentario, por:
detalle = ajustes.tema.etiqueta,- Arriba, justo debajo de
var hojaTema …, el estado interno de la hoja (si está abierta del todo, a medias, cerrándose…):
val estadoHoja = rememberModalBottomSheetState()- La hoja, dentro de
SettingsScreen, después delScaffoldy antes de la llave que cierra la función:
if (hojaTema) {
ModalBottomSheet(onDismissRequest = { hojaTema = false }, sheetState = estadoHoja) {
Text(
"Tema",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
// Una fila por cada valor del enumerado: añadir un Tema al módulo
// compartido lo hace aparecer aquí sin tocar esta pantalla.
Tema.entries.forEach { opcion ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.cambiarTema(opcion) // guarda la elección...
hojaTema = false // ...y cierra la hoja
}
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Default.Check,
contentDescription = null,
// La marca se ve solo en el tema elegido: en los demás es
// transparente, para que el texto no se mueva.
tint = if (opcion == ajustes.tema) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
},
modifier = Modifier.size(20.dp),
)
Text(opcion.etiqueta)
}
}
Text(
"«Lo que diga el sistema» sigue el ajuste de Android.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp),
)
}
}Las opciones salen de Tema.entries, no de una lista escrita a mano. El import de Color ya está en el esqueleto del paso 1.
Ejecútala. Pulsa «Tema»: sube la hoja con tres opciones y la marca en la elegida. Elige «Oscuro»: la hoja se cierra y la fila dice «Oscuro»… pero la app no se oscurece: el tema se guarda y nadie lo aplica todavía. Es la lección siguiente.
Punto de control
- La barra inferior tiene tres pestañas y
SettingsScreense abre en la tercera. - «Tema» abre una hoja con tres opciones y la marca en la elegida; la fila pasa a decir «Claro», «Oscuro» o «Lo que diga el sistema».
- El interruptor de orden cambia su texto y sigue como lo dejaste después de cerrar y abrir la app.
- «Acerca de» no responde al toque (no tiene acción).
- Todavía no se oscurece la app al elegir «Oscuro»: eso se aplica en la lección siguiente (el orden de favoritos sí funciona ya).
- Esta lección no añade tests:
./gradlew :shared:jvmTestsigue dando 76 (la cifra de tests se lee como en las lecciones anteriores; la de Android host sigue en 40 y la de iOS en 43).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿De dónde salen las tres opciones de la hoja inferior del tema?
2.¿Por qué la fila del interruptor es pulsable además del propio
Switch?3.En
Fila, el parámetroicono: @Composable () -> Unites un hueco. ¿Qué significa?4.¿Qué hace que la fila «Acerca de» no responda al toque, si usa la misma
Filaque las otras?5.¿Por qué
Tema.etiquetase escribe en la app de Android y no enshared?