App completa de ejemplo de uso de estados en Jetpack Compose con Kotlin en el IDE Android Studio. Aplicación Android con varios TextField (cuadros de texto), con reutilización de componentes, Text (etiquetas de texto), Switch (interruptores) y Button (botones). Trabajamos con el teclado numérico y algunos de sus eventos y modificadores. Calcula el importe de beneficio a partir de un importe base y un porcentaje de beneficio. Aplica redondeo a petición.
- Estados en Compose, la app de ejemplo.
- Desarrollar App Android con ejemplo de estados Jetpack Compose en Kotlin.
- La app de estados en Compose en funcionamiento en dispositivo Android.
- Descarga del código completo en Kotlin y Android Studio de la app de estados en Compose.
Estados en Compose, la app de ejemplo
El estado de una app es cualquier valor que puede cambiar con el tiempo. Un estado puede ser desde una base de datos hasta una variable en nuestra app. Todas las apps para Android muestran un estado al usuario. Estos son algunos ejemplos de estado de las apps para Android:
- Cualquier mensaje que mostramos al usuario cuando se produce un determinado evento (un error, una finalización de una tarea, etc.).
- Formularios de login (inicio de sesión), que se completan y envían.
- Controles que se pulsan, como botones. El estado puede ser no presionado, se está presionando (animación de la pantalla) o presionado (una acción onClick).
- Un cuadro de texto que al escribir use el evento onValueChanged (cuando se escribe texto) para ejecutar una función. Este es el caso de uso de este tutorial.
- Teclado numérico al que le programa el estado de keyboardActions para enfocar el siguiente componente o bien cerrar el teclado numérico.
Para comprobar el funcionamiento de los estados en Jetpack Compose, crearemos una app de ejemplo con dos TextField, al introducir valores en los dos TextField (importe base y porcentaje de beneficio), se calculará el importe de beneficio según el porcentaje indicado. Al pulsar en el TextField de importe base, aparecerá un teclado numérico con la tecla de «Siguiente», que si se pulsa pasará el foco al siguiente TextField de porcentaje de beneficio. En el porcentaje de beneficio, la tecla del teclado numérico de acción cambiará a «Hecho», al pulsarla se cerrará el teclado numérico. También se añaden varios interruptores (Switch) para mostrar el importe de beneficio con redondeo al alza, redondeo normal o bien sin redondeo. Se aprovechan los estados para activar o desactivar los interruptores en función del que se haya activado. La app también incluye un Button (botón) para copiar el beneficio calculado al portapapeles del móvil.
La app se ha desarrollado en Android Studio Ladybug 2024.2.2 con API 24, por lo que necesitaremos disponer de este IDE instalado en el equipo. En el siguiente enlace explicamos cómo instalar Android Studio y crear un primer proyecto:
Desarrollar App Android con ejemplo de estados Jetpack Compose en Kotlin
En primer lugar crearemos el fichero de strings.xml en la ruta res\values, para definir los textos que aparecerán en la App:

Con el siguiente contenido:
1 2 3 4 5 6 7 8 9 10 |
<resources> <string name="app_name">ProyectoA Beneficio</string> <string name="texto_informativo">Introduzca un importe base y un % de beneficio, la app calculará el importe de beneficio:</string> <string name="importe_base">Importe base</string> <string name="importe_beneficio">Beneficio: %s</string> <string name="porcentaje_beneficio">% beneficio</string> <string name="redondear_beneficio_alza">Redondear beneficio al alza</string> <string name="redondear_beneficio">Redondear beneficio normal</string> <string name="sin_redondeo">Sin redondeo</string> </resources> |
Crearemos un par de ficheros xml con los símbolos para moneda y porcentaje de beneficio. Para ello, en res/drawable, añadiremos el fichero moneda.xml:

Con el siguiente contenido:
1 2 3 4 5 6 |
<vector android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> <path android:fillColor="@android:color/white" android:pathData="M5,8h2v8L5,16zM12,8L9,8c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h3c0.55,0 1,-0.45 1,-1L13,9c0,-0.55 -0.45,-1 -1,-1zM11,14h-1v-4h1v4zM18,8h-3c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h3c0.55,0 1,-0.45 1,-1L19,9c0,-0.55 -0.45,-1 -1,-1zM17,14h-1v-4h1v4z"/> <path android:fillColor="@android:color/white" android:pathData="M2,4v16h20L22,4L2,4zM4,18L4,6h16v12L4,18z"/> </vector> |
Añadiremos también el fichero porcentaje.xml, con el contenido:
1 2 3 4 5 6 7 |
<vector android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> <path android:fillColor="@android:color/white" android:pathData="M7.5,11C9.43,11 11,9.43 11,7.5S9.43,4 7.5,4S4,5.57 4,7.5S5.57,11 7.5,11zM7.5,6C8.33,6 9,6.67 9,7.5S8.33,9 7.5,9S6,8.33 6,7.5S6.67,6 7.5,6z"/> <path android:fillColor="@android:color/white" android:pathData="M4.0025,18.5831l14.5875,-14.5875l1.4142,1.4142l-14.5875,14.5875z"/> <path android:fillColor="@android:color/white" android:pathData="M16.5,13c-1.93,0 -3.5,1.57 -3.5,3.5s1.57,3.5 3.5,3.5s3.5,-1.57 3.5,-3.5S18.43,13 16.5,13zM16.5,18c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5s1.5,0.67 1.5,1.5S17.33,18 16.5,18z"/> </vector> |
Y el código Kotlin de la aplicación estará ubicado en el fichero MainActivity.kt:

Que tendrá el siguiente contenido (las acciones más importantes están comentadas y explicadas en el propio código):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 |
package proyectoa.com.proyectoaejemploestado import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Context.CLIPBOARD_SERVICE import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.DrawableRes import androidx.annotation.StringRes 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.safeDrawingPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextField 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.focus.FocusDirection import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import java.text.NumberFormat import java.util.Locale class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { Surface( modifier = Modifier.fillMaxSize(), ) { EstadoLayout() } } } } /** * Mostrar el layout completo de la app */ @Composable fun EstadoLayout() { //Declaramos las variables mutables para controlar los estados de los controles var importeEntrada by remember { mutableStateOf("") } var porcBeneficioEntrada by remember { mutableStateOf("") } var redondeoEntradaAlza by remember { mutableStateOf(false) } var redondeoEntradaNormal by remember { mutableStateOf(false) } var noRedondeoEntrada by remember { mutableStateOf(true)} // Convertimos los valores de entrada y realizamos los cálculos val importe = importeEntrada.toDoubleOrNull() ?: 0.0 val porcBeneficio = porcBeneficioEntrada.toDoubleOrNull() ?: 0.0 val importeBeneficio = calcularBeneficio(importe, porcBeneficio, redondeoEntradaAlza, redondeoEntradaNormal) val context = LocalContext.current Column( modifier = Modifier .statusBarsPadding() .padding(horizontal = 40.dp) .verticalScroll(rememberScrollState()) .safeDrawingPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, modifier = Modifier .padding(bottom = 1.dp, top = 10.dp) .align(alignment = Alignment.CenterHorizontally) ) Text( text = stringResource(R.string.texto_informativo), style = MaterialTheme.typography.titleMedium, modifier = Modifier .padding(bottom = 16.dp, top = 10.dp) .align(alignment = Alignment.Start) ) val focusManager = LocalFocusManager.current // Mostramos el campo para introducir el importe // Establecemos el teclado de tipo numérico // Establecemos la tecla de acción "Siguiente" CampoNumero( label = R.string.importe_base, leadingIcon = R.drawable.moneda, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next ), keyboardActions = KeyboardActions ( onNext = { focusManager.moveFocus(FocusDirection.Down) } ), value = importeEntrada, onValueChanged = { importeEntrada = it }, modifier = Modifier .padding(bottom = 20.dp) .fillMaxWidth(), ) // Mostramos el campo para introducir el porcentaje de beneficio // Establecemos el teclado de tipo numérico // Establecemos la tecla de acción "Validar" CampoNumero( label = R.string.porcentaje_beneficio, leadingIcon = R.drawable.porcentaje, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() }), value = porcBeneficioEntrada, onValueChanged = { porcBeneficioEntrada = it }, modifier = Modifier .padding(bottom = 25.dp) .fillMaxWidth(), ) // Mostramos el campo de switch (interruptor) para Sin redondeo CampoNoRedondeo( roundUp = noRedondeoEntrada, onRoundUpChanged = { // Si se activa el No redondeo, se desactivan los redondeos noRedondeoEntrada = it if (noRedondeoEntrada) { redondeoEntradaAlza = false redondeoEntradaNormal = false } // Si no se activa el No redondeo, activar el redondeo al alza if (!noRedondeoEntrada && !redondeoEntradaAlza && !redondeoEntradaNormal) redondeoEntradaAlza = true }, modifier = Modifier.padding(bottom = 10.dp) ) // Mostramos el campo de switch (interruptor) para Redondear benieficio al alza CampoRedondeoAlza( roundUp = redondeoEntradaAlza, onRoundUpChanged = { redondeoEntradaAlza = it // Si se activa el redondeo al alza, desactivar el redondeo normal y el sin redondeo if (redondeoEntradaAlza) { redondeoEntradaNormal = false noRedondeoEntrada = false } }, modifier = Modifier.padding(bottom = 10.dp) ) // Mostramos el campo de switch (interruptor) para Redondear benieficio normal CampoRedondeoNormal( roundUp = redondeoEntradaNormal, onRoundUpChanged = { redondeoEntradaNormal = it // Si se activa el redondeo normal, desactivar el redondeo al alza y el sin redondeo if (redondeoEntradaNormal) { redondeoEntradaAlza = false noRedondeoEntrada = false } }, modifier = Modifier.padding(bottom = 20.dp) ) // Mostramos el importe de beneficio calculado Text( text = stringResource(R.string.importe_beneficio, importeBeneficio), style = MaterialTheme.typography.titleLarge ) // Mostramos el botón copiar el beneficio al portapapeles BotonCopiar { copiarTextoPortapapeles(importeBeneficio, context) } } } /** * Mostrar un campo de introducción de texto con teclado numérico * Reutilizable: esta función servirá para dibujar todos los cuadros que se necesiten */ @Composable fun CampoNumero( @StringRes label: Int, @DrawableRes leadingIcon: Int, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, value: String, onValueChanged: (String) -> Unit, modifier: Modifier = Modifier ) { TextField( value = value, singleLine = true, leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) }, modifier = modifier, onValueChange = onValueChanged, label = { Text(stringResource(label)) }, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions ) } /** * Mostrar un switch (interruptor) para redondear el beneficio * con redondeo al alza */ @Composable fun CampoRedondeoAlza( roundUp: Boolean, onRoundUpChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.redondear_beneficio_alza)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = roundUp, onCheckedChange = onRoundUpChanged ) } } /** * Mostrar un switch (interruptor) para redondear el beneficio * con redondeo normal */ @Composable fun CampoRedondeoNormal( roundUp: Boolean, onRoundUpChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.redondear_beneficio)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = roundUp, onCheckedChange = onRoundUpChanged ) } } /** * Mostrar un switch (interruptor) para no redondear el beneficio */ @Composable fun CampoNoRedondeo( roundUp: Boolean, onRoundUpChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text(text = stringResource(R.string.sin_redondeo)) Switch( modifier = Modifier .fillMaxWidth() .wrapContentWidth(Alignment.End), checked = roundUp, onCheckedChange = onRoundUpChanged ) } } /** * Calcula el importe de beneficio de un importe dado, aplicando un porcentaje dado * Devolverá el valor en string formateado */ private fun calcularBeneficio(importe: Double, porcentajeBeneficio: Double = 10.0, redondearAlza: Boolean, redondearNormal: Boolean): String { var beneficio = porcentajeBeneficio / 100 * importe if (redondearAlza) { beneficio = kotlin.math.ceil(beneficio) } if (redondearNormal) { beneficio = kotlin.math.round(beneficio) } val beneficioFormateado = formatearNumeroSeparadorMiles(beneficio,"€") return beneficioFormateado } /** * Botón para copiar el beneficio al portapapeles */ @Composable fun BotonCopiar(onClick: () -> Unit) { Button(onClick = { onClick() }) { Text("Copiar") } } /** * Formatea un número para devolver separador de miles y decimal en ES */ fun formatearNumeroSeparadorMiles(numero: Double, moneda: String = "€"): String { try { val formato = NumberFormat.getNumberInstance(Locale("es", "ES")) formato.minimumFractionDigits = 2 formato.maximumFractionDigits = 2 val numFormateado = formato.format(numero) + moneda return numFormateado } catch (e: Exception) { return "0,00" } } /** * Copia el texto pasado como parámetro al portapapeles */ fun copiarTextoPortapapeles (texto: String, context: Context) { val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clipData = ClipData.newPlainText("label", texto) clipboard.setPrimaryClip(clipData) } |
La app de estados en Compose en funcionamiento en dispositivo Android
Si compilamos la app, tendrá este aspecto:

Al introducir un importe base y un porcentaje de beneficio, la app mostrará automáticamente el importe de beneficio. Al iniciar calculará el importe de beneficio sin redondear (con el interruptor «Sin redondeo activado». En el campo de importe base mostrará el teclado numérico con la tecla «Siguiente» (si se pulsa pasará al campo de porcentaje de beneficio). En el campo de porcentaje de beneficio la tecla de acción pasará a ser «Hecho». Si se pulsa se cerrará el teclado numérico:

Si pulsamos en el interruptor de «Redondear beneficio al alza», se desmarcará el interruptor de «Sin redondeo» y se recalculará el importe de beneficio redondeando el resultado al alza:

Lo mismo ocurrirá si se activa el interruptor «Redondear beneficio normal». Si se pulsa el botón «Copiar», se copiará el beneficio calculado al portapapeles del móvil.
Descarga del código completo en Kotlin y Android Studio de la app de estados en Compose
En el siguiente enlace tenéis disponible la descarga del proyecto completo y todo su código fuente en Kotlin y Android Studio: