Android · Kotlin · Material 3
Jetpack Compose Cheatsheet
Guía rápida para crear interfaces en Android con Jetpack Compose: desde los modificadores y layouts básicos hasta el estado, el ViewModel y la navegación. Con ejemplos listos para copiar.
01 Fundamentos
En Compose la interfaz se escribe con funciones de Kotlin. Cada función describe cómo se ve una parte de la pantalla según los datos que recibe.
@Composable y setContent
Una función marcada con @Composable pinta interfaz. Su nombre empieza por mayúscula (como una clase) y no devuelve nada. La Activity carga la primera pantalla con setContent.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MiAppTheme {
Saludo(nombre = "Eva")
}
}
}
}
@Composable
fun Saludo(nombre: String, modifier: Modifier = Modifier) {
Text(text = "Hola, $nombre", modifier = modifier)
}
Buena práctica: que cada composable reciba un modifier: Modifier = Modifier como primer parámetro opcional y lo aplique a su elemento raíz. Así quien lo usa puede darle tamaño, márgenes o posición.
@Preview
Muestra el composable en el panel de diseño de Android Studio sin ejecutar la app.
@Preview(showBackground = true, name = "Claro")
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Oscuro")
@Composable
fun SaludoPreview() {
MiAppTheme {
Saludo(nombre = "Android")
}
}
Recomposición
Cuando cambia un dato que la interfaz está leyendo (un estado), Compose vuelve a ejecutar solo los composables afectados. A esto se le llama recomposición.
| Regla | Por qué |
| No guardes estado en variables normales | Se pierden en cada recomposición. Usa remember { mutableStateOf(...) }. |
| Sin efectos secundarios en el cuerpo | Un composable puede ejecutarse muchas veces. Las llamadas a red o base de datos van en un efecto o en el ViewModel. |
| Los datos bajan, los eventos suben | El padre pasa valores al hijo y el hijo avisa con lambdas (onClick, onValueChange…). |
02 Modifier
Los modificadores se encadenan para definir el tamaño, el aspecto y el comportamiento de los composables.
Tamaño y espacio
| Modificador | Descripción |
fillMaxSize() | Ocupa todo el espacio disponible |
fillMaxWidth() | Se estira horizontalmente todo lo posible |
fillMaxWidth(0.5f) | Ocupa la mitad del ancho disponible |
height(50.dp) | Altura específica |
width(140.dp) | Ancho específico |
size(140.dp, 180.dp) | Ancho y alto juntos (size(48.dp) para un cuadrado) |
padding(24.dp) | Margen interno en los cuatro lados |
padding(horizontal = 16.dp, vertical = 8.dp) | Margen distinto en horizontal y vertical |
aspectRatio(16f / 9f) | Mantiene una proporción ancho/alto |
weight(1f) | Reparte el espacio sobrante de forma proporcional. Solo dentro de Row o Column |
align(Alignment.Center) | Mueve un único elemento dentro de su padre (en Box; en Row se usa Alignment.CenterVertically) |
offset(x = 8.dp, y = 4.dp) | Desplaza el elemento sin cambiar el sitio que ocupa |
Aspecto
| Modificador | Descripción |
background(colorResource(R.color.BlancoGrisaceo)) | Color de fondo desde res/values/colors.xml |
background(Color.LightGray, RoundedCornerShape(12.dp)) | Fondo con forma |
clip(RoundedCornerShape(12.dp)) | Recorta el contenido con esa forma (CircleShape para círculos) |
border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) | Borde alrededor |
shadow(6.dp, RoundedCornerShape(12.dp)) | Sombra con forma |
alpha(0.5f) | Transparencia (de 0.0 a 1.0) |
Interacción y scroll
| Modificador | Descripción |
clickable { navController.navigate("ProductHistory") } | Pulsable (aquí, navega a otra pantalla) |
verticalScroll(rememberScrollState()) | Permite hacer scroll en una Column con poco contenido |
horizontalScroll(rememberScrollState()) | Scroll horizontal en una Row |
semantics { contentDescription = "…" } | Información para lectores de pantalla |
Ojo: para listas largas no uses verticalScroll, usa LazyColumn: solo crea los elementos que se ven en pantalla.
El orden importa
Cada modificador se aplica sobre el resultado del anterior. Cambiar el orden cambia el resultado:
// El fondo cubre también el padding: caja azul grande con el texto dentro
Modifier.background(Color.Blue).padding(16.dp)
// El padding va primero: queda un margen transparente alrededor del fondo
Modifier.padding(16.dp).background(Color.Blue)
// clip antes de clickable: la onda del clic respeta las esquinas redondeadas
Modifier.clip(RoundedCornerShape(12.dp)).clickable { }
03 Layouts y alineación
Row coloca en horizontal, Column en vertical y Box apila unos encima de otros.
Row()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { /* elementos */ }
Column()
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) { /* elementos */ }
horizontalArrangement Row
Cómo se reparten los elementos a lo largo de la fila.
| Valor | Comportamiento |
Start | Alinea los elementos al principio (izquierda) |
End | Alinea los elementos al final (derecha) |
Center | Los agrupa todos en el centro horizontal |
SpaceBetween | Primero y último en los bordes; espacio restante en medio |
SpaceAround | Espacio uniforme; los extremos con la mitad de espacio |
SpaceEvenly | Espacio exactamente igual entre elementos y bordes |
spacedBy(8.dp) | Hueco fijo de 8dp entre cada elemento |
verticalArrangement Column
Cómo se reparten los elementos a lo largo de la columna.
| Valor | Comportamiento |
Top | Alinea los elementos en la parte superior |
Bottom | Alinea los elementos en la parte inferior |
Center | Los agrupa todos en el centro vertical |
SpaceBetween | Primero arriba, último abajo; espacio repartido en medio |
SpaceAround | Espacio uniforme (los extremos con la mitad de espacio) |
SpaceEvenly | Espacio idéntico en todas partes |
spacedBy(16.dp) | Hueco fijo de 16dp entre cada elemento |
verticalAlignment Row
Posición en el otro eje (vertical) de una fila.
| Valor | Efecto |
Top | Borde superior |
CenterVertically | Centra verticalmente |
Bottom | Borde inferior |
horizontalAlignment Column
Posición en el otro eje (horizontal) de una columna.
| Valor | Efecto |
Start | Al inicio (izquierda) |
CenterHorizontally | Centra horizontalmente |
End | Al final (derecha) |
Box() y contentAlignment
Los hijos se apilan; el último queda encima. Para colocarlos se usa contentAlignment en el Box o Modifier.align() en cada hijo:
TopStartTopCenterTopEnd
CenterStartCenterCenterEnd
BottomStartBottomCenterBottomEnd
| Propiedad | Descripción |
contentAlignment = Alignment.Center | Alinea los hijos en el centro (por defecto TopStart) |
propagateMinConstraints = true | Obliga a los hijos a medir al menos lo mismo que el Box |
Modifier.matchParentSize() (hijos) | El hijo mide exactamente lo mismo que el Box |
Box(modifier = Modifier.size(120.dp)) {
Image(painterResource(R.drawable.foto), contentDescription = null, modifier = Modifier.matchParentSize())
Text("Oferta", modifier = Modifier.align(Alignment.TopEnd).padding(8.dp))
}
Spacer()
| Modificador | Uso |
height(16.dp) | Espacio vertical (en Column) |
width(8.dp) | Espacio horizontal (en Row) |
size(20.dp) | Cuadrado invisible de tamaño fijo |
weight(1f) | Truco clave: empuja los elementos a extremos opuestos |
contentPadding = PaddingValues()
Margen interior de listas, botones y Scaffold.
| Uso | Sintaxis |
| Igual en los 4 lados | PaddingValues(16.dp) |
| Horizontal / vertical | PaddingValues(horizontal = 16.dp, vertical = 8.dp) |
| Por lado | PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 80.dp) |
04 Listas y grids
Las listas Lazy solo crean los elementos visibles, así que sirven para cientos o miles de filas.
LazyColumn / LazyRow Nuevo
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Text("Cabecera", style = MaterialTheme.typography.titleLarge)
}
items(productos, key = { it.id }) { producto ->
ProductoCard(producto)
}
}
| Función | Para qué |
item { } | Un único elemento (cabecera, pie…) |
items(lista) { } | Un elemento por cada objeto de la lista |
itemsIndexed(lista) { i, x -> } | Igual, pero con la posición |
key = { it.id } | Identifica cada fila: evita fallos al borrar o reordenar |
stickyHeader { } | Cabecera que se queda fija al hacer scroll |
state = rememberLazyListState() | Para saber o cambiar la posición del scroll |
Import: items(lista) necesita import androidx.compose.foundation.lazy.items. Si Android Studio marca error con una lista, suele ser esto.
LazyVerticalGrid()
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(fotos) { foto -> FotoItem(foto) }
}
| Propiedad | Descripción |
columns = GridCells.Fixed(3) | Exactamente 3 columnas |
columns = GridCells.Adaptive(128.dp) | Tantas columnas como quepan, de 128dp como mínimo |
span = { GridItemSpan(maxLineSpan) } | Un elemento que ocupa toda la fila (en item) |
05 Texto y campos
Text()
| Propiedad | Descripción | Ejemplo |
text | El contenido escrito | "Hola" |
color | Color del texto | Color.Red |
fontSize | Tamaño de letra (usa .sp) | 20.sp |
fontStyle | Estilo de fuente | FontStyle.Italic |
fontWeight | Grosor de la letra | FontWeight.Bold |
fontFamily | Tipo de fuente | FontFamily.Monospace |
letterSpacing | Espacio entre letras | 2.sp |
textDecoration | Subrayado / tachado | TextDecoration.Underline |
textAlign | Alineación del texto | TextAlign.Center |
lineHeight | Altura de línea | 30.sp |
overflow | Puntos suspensivos si no cabe | TextOverflow.Ellipsis |
maxLines | Límite máximo de líneas | 2 |
style | Estilo predefinido del tema | MaterialTheme.typography.bodyLarge |
Textos en recursos: Text(stringResource(R.string.titulo)) lee de strings.xml y permite traducir la app.
TextField / OutlinedTextField
TextField tiene fondo relleno y OutlinedTextField solo borde. Los dos se usan igual y necesitan un estado donde guardar lo escrito.
| Propiedad | Descripción |
value = textState | Valor actual del texto (estado con remember) |
onValueChange = { textState = it } | Se ejecuta al escribir y actualiza el estado |
label = { Text("Nombre") } | Texto flotante indicador |
placeholder = { Text("Ej: Juan") } | Texto de pista (desaparece al escribir) |
leadingIcon = { Icon(...) } | Icono al inicio del campo |
trailingIcon = { Icon(...) } | Icono al final del campo |
supportingText = { Text("Obligatorio") } | Texto de ayuda o de error debajo del campo |
isError = true | Borde rojo si hay error |
visualTransformation = PasswordVisualTransformation() | Oculta el texto (contraseña) |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) | Tipo de teclado |
keyboardActions = KeyboardActions(onDone = { ... }) | Acción al pulsar Intro |
singleLine = true | Fuerza una sola línea |
maxLines = 2 | Máximo de líneas antes del scroll interno |
shape = RoundedCornerShape(12.dp) | Redondeo de esquinas |
Buscador Ejemplo
@Composable
fun SearchField(label: String, modifier: Modifier = Modifier) {
var search by remember { mutableStateOf("") }
TextField(
value = search,
onValueChange = { search = it },
label = { Text(text = label) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
modifier = modifier
)
}
Para filtrar una lista con lo que se escribe, el texto tiene que estar en el padre: mira la elevación de estado y el ejemplo completo.
06 Botones y controles
Button()
| Tipo | Descripción |
Button | Relleno sólido (acción principal) |
FilledTonalButton | Fondo con color suave (tonal) |
ElevatedButton | Con sombra |
OutlinedButton | Borde sin fondo (acción secundaria) |
TextButton | Solo texto, sin bordes ni fondo |
IconButton | Botón que solo lleva un icono |
FloatingActionButton | Botón flotante para la acción principal de la pantalla |
Propiedades comunes
| Propiedad | Descripción |
onClick = { /* Acción */ } | Código al pulsar |
enabled = true | false → gris e inactivo |
shape = RoundedCornerShape(8.dp) | Forma de las esquinas |
elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp) | Sombra del botón |
colors = ButtonDefaults.buttonColors(containerColor = ..., contentColor = ...) | Colores de fondo y contenido |
border = BorderStroke(1.dp, Color.Blue) | Borde alrededor |
contentPadding = PaddingValues(16.dp) | Margen interno |
Button(onClick = { guardar() }) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Guardar")
}
Checkbox, Switch y RadioButton Nuevo
Funcionan igual que un campo de texto: un estado con el valor y una lambda que lo cambia.
var aceptado by remember { mutableStateOf(false) }
var notificaciones by remember { mutableStateOf(true) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = aceptado, onCheckedChange = { aceptado = it })
Text("Acepto las condiciones")
}
Switch(checked = notificaciones, onCheckedChange = { notificaciones = it })
val opciones = listOf("Pequeño", "Mediano", "Grande")
var elegida by remember { mutableStateOf(opciones[0]) }
opciones.forEach { opcion ->
Row(
Modifier.fillMaxWidth().selectable(selected = opcion == elegida, onClick = { elegida = opcion }),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = opcion == elegida, onClick = null)
Text(opcion)
}
}
Fila entera pulsable: con Modifier.selectable en la fila y onClick = null en el RadioButton, el usuario puede tocar también el texto.
Slider Nuevo
var volumen by remember { mutableFloatStateOf(5f) }
Slider(
value = volumen,
onValueChange = { volumen = it },
valueRange = 0f..10f,
steps = 9 // 9 paradas intermedias: valores enteros del 0 al 10
)
Text("Volumen: ${volumen.toInt()}")
07 Card, Scaffold y diálogos
Card()
| Propiedad | Descripción |
modifier = Modifier.padding(16.dp) | Margen externo |
shape = RoundedCornerShape(12.dp) | Curvatura de las esquinas |
colors = CardDefaults.cardColors(containerColor = ...) | Color de fondo |
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) | Tamaño de la sombra |
border = BorderStroke(1.dp, Color.Black) | Borde (lo trae por defecto OutlinedCard) |
onClick = { ... } | Hace la tarjeta pulsable |
También existen ElevatedCard (con sombra) y OutlinedCard (con borde). Surface es la versión más básica: un fondo con color, forma y sombra.
Scaffold con barra superior y botón flotante Nuevo
Es el esqueleto de una pantalla: coloca la barra superior, la inferior, el botón flotante y los avisos, y te pasa el espacio que queda libre.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaLista() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Mis tareas") },
actions = {
IconButton(onClick = { /* ajustes */ }) {
Icon(Icons.Default.Settings, contentDescription = "Ajustes")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { /* nueva tarea */ }) {
Icon(Icons.Default.Add, contentDescription = "Añadir")
}
}
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
// ...
}
}
}
No ignores innerPadding: si no lo aplicas, el contenido queda tapado por las barras.
| Parámetro | Qué va dentro |
topBar | TopAppBar, CenterAlignedTopAppBar… |
bottomBar | NavigationBar con NavigationBarItem (pestañas inferiores) |
floatingActionButton | FloatingActionButton o ExtendedFloatingActionButton |
snackbarHost | SnackbarHost(snackbarHostState) para avisos temporales |
AlertDialog Nuevo
El diálogo se muestra mientras una condición sea verdadera.
var mostrarDialogo by remember { mutableStateOf(false) }
if (mostrarDialogo) {
AlertDialog(
onDismissRequest = { mostrarDialogo = false },
title = { Text("¿Borrar tarea?") },
text = { Text("Esta acción no se puede deshacer.") },
confirmButton = {
TextButton(onClick = { borrar(); mostrarDialogo = false }) { Text("Borrar") }
},
dismissButton = {
TextButton(onClick = { mostrarDialogo = false }) { Text("Cancelar") }
}
)
}
Snackbar Nuevo
Aviso breve en la parte inferior. Se muestra desde una corrutina, porque espera a que desaparezca.
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { innerPadding ->
Button(
onClick = { scope.launch { snackbarHostState.showSnackbar("Guardado") } },
modifier = Modifier.padding(innerPadding)
) { Text("Guardar") }
}
08 Iconos e imágenes
Icon()
| Propiedad | Descripción |
imageVector = Icons.Default.Add | Dibujo del icono (estilos: Default, Rounded, Outlined, Sharp, TwoTone) |
contentDescription = "Añadir" | Para lectores de pantalla. null si es decorativo |
tint = Color.Red | Color del icono |
modifier = Modifier.size(24.dp) | Tamaño (el estándar es 24dp) |
painter = painterResource(R.drawable.mi_icono) | Icono propio desde res/drawable |
¿Faltan iconos? El paquete básico trae pocos. Para tener todos, añade la dependencia androidx.compose.material:material-icons-extended, o importa iconos sueltos desde Android Studio (New → Vector Asset).
Image()
| Propiedad | Descripción |
painter = painterResource(R.drawable.mi_imagen) | Carga desde res/drawable |
contentDescription = "Descripción" | Accesibilidad |
modifier = Modifier.clip(CircleShape) | Recorta (por ejemplo, en círculo) |
contentScale = ContentScale.Crop | Ajuste al contenedor: Crop recorta, Fit encaja sin recortar |
alpha = 0.5f | Transparencia (de 0.0 a 1.0) |
Imágenes de internet con Coil Nuevo
Compose no descarga imágenes por sí solo. La librería más usada es Coil: añade io.coil-kt.coil3:coil-compose y io.coil-kt.coil3:coil-network-okhttp, y el permiso de internet en el AndroidManifest.xml.
AsyncImage(
model = producto.urlFoto,
contentDescription = producto.nombre,
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.cargando),
error = painterResource(R.drawable.sin_foto),
modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp))
)
<uses-permission android:name="android.permission.INTERNET" />
09 Estado Nuevo
El estado es cualquier dato que puede cambiar y que la pantalla muestra. Cuando cambia, Compose repinta lo necesario.
remember + mutableStateOf
@Composable
fun Contador() {
var contador by remember { mutableStateOf(0) }
Button(onClick = { contador++ }) {
Text("Pulsado $contador veces")
}
}
| Pieza | Qué hace |
mutableStateOf(0) | Crea un valor observable: al cambiarlo, se repinta lo que lo lee |
remember { } | Lo conserva entre recomposiciones |
by | Permite usar contador directamente en lugar de contador.value |
rememberSaveable { } | Igual que remember, pero sobrevive al girar la pantalla |
Si by da error, faltan estos imports: import androidx.compose.runtime.getValue y import androidx.compose.runtime.setValue.
Tipos de estado
| Función | Para qué |
mutableStateOf("") | Cualquier valor (texto, booleano, objeto…) |
mutableIntStateOf(0) / mutableFloatStateOf(0f) | Números, sin conversiones internas |
mutableStateListOf<Tarea>() | Lista observable: avisa al añadir o borrar elementos |
derivedStateOf { } | Valor calculado a partir de otros estados, que solo cambia cuando cambia el resultado |
Error típico: mutableStateOf(mutableListOf()) y luego lista.add(x) no repinta, porque la lista es la misma. Usa mutableStateListOf() o crea una lista nueva: lista = lista + x.
Elevación de estado (state hoisting)
Si dos composables necesitan el mismo dato, el estado se sube al padre común. El hijo recibe el valor y un evento para cambiarlo, así no guarda nada y es fácil de reutilizar y probar.
// Hijo sin estado: solo muestra y avisa
@Composable
fun Buscador(texto: String, onTextoChange: (String) -> Unit) {
OutlinedTextField(value = texto, onValueChange = onTextoChange, label = { Text("Buscar") })
}
// Padre con el estado: lo comparte con el buscador y con la lista
@Composable
fun PantallaProductos(productos: List<Producto>) {
var texto by rememberSaveable { mutableStateOf("") }
val filtrados = productos.filter { it.nombre.contains(texto, ignoreCase = true) }
Column {
Buscador(texto = texto, onTextoChange = { texto = it })
ListaProductos(filtrados)
}
}
10 ViewModel Nuevo
El ViewModel guarda el estado de una pantalla fuera de la interfaz: sobrevive a los giros y separa la lógica de lo que se pinta (patrón MVVM).
ViewModel + StateFlow
data class TareasUiState(
val tareas: List<Tarea> = emptyList(),
val cargando: Boolean = false
)
class TareasViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TareasUiState())
val uiState: StateFlow<TareasUiState> = _uiState.asStateFlow()
fun anadir(titulo: String) {
_uiState.update { estado ->
estado.copy(tareas = estado.tareas + Tarea(titulo))
}
}
}
La pantalla obtiene el ViewModel y lee su estado:
@Composable
fun PantallaTareas(viewModel: TareasViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LazyColumn {
items(uiState.tareas) { tarea -> Text(tarea.titulo) }
}
}
| Pieza | Qué hace |
_uiState privado | Solo el ViewModel puede cambiar el estado |
uiState público | La pantalla solo puede leerlo |
update { it.copy(...) } | Crea un estado nuevo a partir del anterior |
viewModel() | Crea el ViewModel o reutiliza el que ya existe |
collectAsStateWithLifecycle() | Convierte el StateFlow en estado de Compose y deja de escuchar cuando la app está en segundo plano |
Dependencias: androidx.lifecycle:lifecycle-viewmodel-compose (para viewModel()) y androidx.lifecycle:lifecycle-runtime-compose (para collectAsStateWithLifecycle()).
Operaciones lentas: viewModelScope
Las llamadas a red o a base de datos se lanzan en una corrutina del ViewModel, que se cancela sola si se cierra la pantalla.
fun cargar() {
viewModelScope.launch {
_uiState.update { it.copy(cargando = true) }
val tareas = repositorio.obtenerTareas() // función suspend
_uiState.update { it.copy(tareas = tareas, cargando = false) }
}
}
11 Navegación Nuevo
Con androidx.navigation:navigation-compose cada pantalla es un destino dentro de un NavHost.
NavHost con rutas de texto
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "inicio") {
composable("inicio") {
PantallaInicio(onVerDetalle = { id -> navController.navigate("detalle/$id") })
}
composable("detalle/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
PantallaDetalle(id = id, onVolver = { navController.popBackStack() })
}
}
}
| Función | Qué hace |
navController.navigate("perfil") | Va a otra pantalla |
navController.popBackStack() | Vuelve a la pantalla anterior |
navigate("inicio") { popUpTo("login") { inclusive = true } } | Navega y borra el historial hasta login (para que «atrás» no vuelva al login) |
navigate("ajustes") { launchSingleTop = true } | No apila la misma pantalla dos veces |
Buena práctica: no pases el navController a cada pantalla; pásales lambdas como onVerDetalle. Así las pantallas no dependen de la navegación y se pueden previsualizar.
Rutas con tipos (type-safe)
Desde Navigation 2.8 las rutas pueden ser objetos de Kotlin en lugar de textos, y el compilador comprueba los argumentos. Necesita el plugin de kotlinx.serialization.
@Serializable object Inicio
@Serializable data class Detalle(val id: Int)
NavHost(navController, startDestination = Inicio) {
composable<Inicio> {
PantallaInicio(onVerDetalle = { id -> navController.navigate(Detalle(id)) })
}
composable<Detalle> { backStackEntry ->
val detalle: Detalle = backStackEntry.toRoute()
PantallaDetalle(id = detalle.id)
}
}
12 Efectos Nuevo
Código que no pinta interfaz (cargar datos, mostrar avisos, empezar una animación) se ejecuta en un efecto para controlar cuándo y cuántas veces ocurre.
LaunchedEffect
Lanza una corrutina al aparecer el composable y la repite solo si cambia la clave.
@Composable
fun PantallaDetalle(id: Int, viewModel: DetalleViewModel = viewModel()) {
LaunchedEffect(id) {
viewModel.cargar(id) // se ejecuta al entrar y cada vez que cambie id
}
// ...
}
rememberCoroutineScope
Para lanzar corrutinas desde un evento, como un onClick (por ejemplo, un Snackbar o un scroll animado).
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
Button(onClick = {
scope.launch { listState.animateScrollToItem(0) }
}) { Text("Subir") }
DisposableEffect
Para registrar algo al entrar y limpiarlo al salir (sensores, listeners…).
DisposableEffect(Unit) {
sensor.empezar()
onDispose { sensor.parar() }
}
13 Tema y colores Nuevo
Android Studio crea un tema en ui/theme/ (Color.kt, Theme.kt, Type.kt). Usar los colores del tema en lugar de colores fijos hace que el modo oscuro funcione solo.
MaterialTheme
Text(
text = "Título",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium
) { /* ... */ }
| Color | Uso |
primary / onPrimary | Color de marca y el texto que va encima |
secondary, tertiary | Acentos secundarios |
background / onBackground | Fondo de la pantalla y su texto |
surface / onSurface | Tarjetas, barras y hojas |
surfaceVariant | Superficies con un tono distinto |
error | Mensajes y campos con error |
Tipografía del tema
| Estilo | Para qué |
displayLarge … displaySmall | Números o textos muy grandes |
headlineLarge … headlineSmall | Títulos de pantalla |
titleLarge … titleSmall | Títulos de tarjetas y secciones |
bodyLarge … bodySmall | Texto normal |
labelLarge … labelSmall | Botones, etiquetas y textos pequeños |
colorResource o tema: colorResource(R.color.X) lee de colors.xml y está bien para un color concreto, pero no cambia con el modo oscuro. Para la app entera, define los colores en Theme.kt.
14 Ejemplo completo
Una lista de tareas que junta casi todo: Scaffold, campo de texto, estado, LazyColumn con key, Checkbox, el truco del Spacer con weight y un diálogo de confirmación.
Lista de tareas
data class Tarea(val id: Int, val titulo: String, val hecha: Boolean = false)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaTareas() {
val tareas = remember { mutableStateListOf<Tarea>() }
var texto by rememberSaveable { mutableStateOf("") }
var aBorrar by remember { mutableStateOf<Tarea?>(null) }
Scaffold(topBar = { TopAppBar(title = { Text("Tareas (${tareas.count { !it.hecha }})") }) }) { innerPadding ->
Column(Modifier.padding(innerPadding).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = texto,
onValueChange = { texto = it },
label = { Text("Nueva tarea") },
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
tareas.add(Tarea(id = (tareas.maxOfOrNull { it.id } ?: 0) + 1, titulo = texto.trim()))
texto = ""
},
enabled = texto.isNotBlank()
) { Text("Añadir") }
}
Spacer(Modifier.height(16.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(tareas, key = { it.id }) { tarea ->
Card {
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = tarea.hecha,
onCheckedChange = { marcada ->
val i = tareas.indexOf(tarea)
tareas[i] = tarea.copy(hecha = marcada)
}
)
Text(
text = tarea.titulo,
textDecoration = if (tarea.hecha) TextDecoration.LineThrough else null
)
Spacer(Modifier.weight(1f)) // empuja la papelera a la derecha
IconButton(onClick = { aBorrar = tarea }) {
Icon(Icons.Default.Delete, contentDescription = "Borrar")
}
}
}
}
}
}
}
aBorrar?.let { tarea ->
AlertDialog(
onDismissRequest = { aBorrar = null },
title = { Text("¿Borrar «${tarea.titulo}»?") },
confirmButton = { TextButton(onClick = { tareas.remove(tarea); aBorrar = null }) { Text("Borrar") } },
dismissButton = { TextButton(onClick = { aBorrar = null }) { Text("Cancelar") } }
)
}
}
Siguiente paso: mover tareas a un ViewModel para que no se pierdan al girar la pantalla, y guardarlas con Room o DataStore para que sobrevivan al cerrar la app.
No hay resultados.
Prueba con otra palabra, por ejemplo padding, Lazy o estado.