1. Antes de comenzar
En este codelab, usarás el código de la solución del codelab Introducción al estado en Compose para compilar una calculadora interactiva de propinas que puede calcular y redondear automáticamente un importe de propina cuando ingresas el importe de la factura y el porcentaje de propina. Puedes ver la app final en esta imagen:
Requisitos previos
- Haber completado el codelab Introducción al estado en Compose
- Poder agregar los elementos componibles
Text
yTextField
a una app - Conocer la función
remember()
, el estado, la elevación de estado y la diferencia entre las funciones de componibilidad con y sin estado
Qué aprenderás
- Cómo agregar un botón de acción a un teclado virtual
- Qué es un elemento
Switch
componible y cómo usarlo - Cómo agregar íconos iniciales a los campos de texto
Qué compilarás
- Una app de Tip Time que calcula los importes de las propinas según el importe de la factura y el porcentaje de propina ingresados por el usuario
Requisitos
- La versión más reciente de Android Studio
- El código de la solución del codelab Introducción al estado en Compose
2. Obtén el código de inicio
Para comenzar, descarga el código de partida:
Como alternativa, puedes clonar el repositorio de GitHub para el código:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git $ cd basic-android-kotlin-compose-training-tip-calculator $ git checkout state
Puedes explorar el código en el repositorio de GitHub de Tip Time
.
3. Descripción general de la app de inicio
Este codelab comienza con la app de Tip Time del codelab anterior Introducción al estado en Compose, que proporciona la interfaz de usuario necesaria para calcular una propina con un porcentaje fijo de propina. El cuadro de texto Bill amount le permite al usuario ingresar el costo del servicio. La app calcula y muestra el importe de la propina en un elemento Text
componible.
Ejecuta la app de Tip Time
- Abre el proyecto Tip Time en Android Studio y ejecuta la app en un emulador o dispositivo.
- Ingresa un importe de factura. La app calculará y mostrará el importe de la propina automáticamente.
En la implementación actual, el porcentaje de la propina se codifica al 15%. En este codelab, extenderás esta función con un campo de texto que le permite a la app calcular un porcentaje de propina personalizado y redondear el importe.
Agrega los recursos de strings necesarios
- En la pestaña Project, haz clic en res > values > strings.xml.
- Entre las etiquetas
<resources>
del archivostrings.xml
, agrega estos recursos de strings:
<string name="how_was_the_service">Tip Percentage</string>
<string name="round_up_tip">Round up tip?</string>
El archivo strings.xml
debería verse como este fragmento de código, que incluye las cadenas del codelab anterior:
strings.xml
<resources>
<string name="app_name">Tip Time</string>
<string name="calculate_tip">Calculate Tip</string>
<string name="bill_amount">Bill Amount</string>
<string name="how_was_the_service">Tip Percentage</string>
<string name="round_up_tip">Round up tip?</string>
<string name="tip_amount">Tip Amount: %s</string>
</resources>
4. Agrega un campo de texto de porcentaje de propina
Un cliente podría querer una propina mayor o menor según la calidad del servicio proporcionado y otros motivos. Para adecuarse a esto, la app debe permitir que el usuario calcule una propina personalizada. En esta sección, agregarás un campo de texto para que el usuario ingrese un porcentaje de propina personalizado, como se muestra en esta imagen:
Ya tienes un campo de texto componible para el Importe de facturación en tu app, que es la función de componibilidad sin estado EditNumberField()
. En el codelab anterior, elevaste el estado amountInput
del elemento EditNumberField()
componible al elemento TipTimeLayout()
componible, lo que hizo que el elemento EditNumberField()
no tuviera estado.
Para agregar un campo de texto, puedes volver a usar el mismo elemento EditNumberField()
componible, pero con una etiqueta diferente. Para realizar este cambio, debes pasar la etiqueta como parámetro, en lugar de codificarla dentro de la función de componibilidad EditNumberField()
.
Haz que la función de componibilidad EditNumberField()
sea reutilizable:
- En el archivo
MainActivity.kt
, en los parámetros de la función de componibilidadEditNumberField()
, agrega un recurso de stringslabel
de tipoInt
:
@Composable
fun EditNumberField(
label: Int,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier
)
- En el cuerpo de la función, reemplaza el ID de recurso de cadenas codificado con el parámetro
label
:
@Composable
fun EditNumberField(
//...
) {
TextField(
//...
label = { Text(stringResource(label)) },
//...
)
}
- Para indicar que se espera que el parámetro
label
sea una referencia de recursos de strings, anota el parámetro de la función con la anotación@StringRes
:
@Composable
fun EditNumberField(
@StringRes label: Int,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier
)
- Importa lo siguiente:
import androidx.annotation.StringRes
- En la llamada a función
EditNumberField()
de la función de componibilidadTipTimeLayout()
, establece el parámetrolabel
en el recurso de cadenasR.string.bill_amount
:
EditNumberField(
label = R.string.bill_amount,
value = amountInput,
onValueChanged = { amountInput = it },
modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)
- En el panel Preview, no debería haber ningún cambio visual.
- En la función de componibilidad
TipTimeLayout()
, después de la llamada a funciónEditNumberField()
, agrega otro campo de texto para el porcentaje de propina personalizado. Realiza una llamada a la función de componibilidadEditNumberField()
con estos parámetros:
EditNumberField(
label = R.string.how_was_the_service,
value = "",
onValueChanged = { },
modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)
Esto agrega otro cuadro de texto para el porcentaje de propina personalizado.
- La vista previa de la app ahora muestra un campo de texto Tip Percentage como se puede ver en esta imagen:
- En la parte superior de la función de componibilidad
TipTimeLayout()
, agrega una propiedadvar
llamadatipInput
para la variable de estado del campo de texto agregado. UsamutableStateOf("")
para inicializar la variable y rodea la llamada con la funciónremember
:
var tipInput by remember { mutableStateOf("") }
- En la nueva llamada a función
EditNumberField
()
, configura el parámetro con nombrevalue
para la variabletipInput
y, luego, actualiza la variabletipInput
en la expresión lambdaonValueChanged
:
EditNumberField(
label = R.string.how_was_the_service,
value = tipInput,
onValueChanged = { tipInput = it },
modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)
- En la función
TipTimeLayout()
, después de la definición de la variabletipInput
. Define un elementoval
llamadotipPercent
que convierta la variabletipInput
en un tipoDouble
. Usa un operador Elvis y muestra0
si el valor esnull
. Este valor podría sernull
si el campo de texto está vacío.
val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
- En la función
TipTimeLayout()
, actualiza la llamada a funcióncalculateTip()
y pasa la variabletipPercent
como segundo parámetro:
val tip = calculateTip(amount, tipPercent)
El código para la función TipTimeLayout()
debería verse de la siguiente manera:
@Composable
fun TipTimeLayout() {
var amountInput by remember { mutableStateOf("") }
var tipInput by remember { mutableStateOf("") }
val amount = amountInput.toDoubleOrNull() ?: 0.0
val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
val tip = calculateTip(amount, tipPercent)
Column(
modifier = Modifier.padding(40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.calculate_tip),
modifier = Modifier
.padding(bottom = 16.dp)
.align(alignment = Alignment.Start)
)
EditNumberField(
label = R.string.bill_amount,
value = amountInput,
onValueChanged = { amountInput = it },
modifier = Modifier
.padding(bottom = 32.dp)
.fillMaxWidth()
)
EditNumberField(
label = R.string.how_was_the_service,
value = tipInput,
onValueChanged = { tipInput = it },
modifier = Modifier
.padding(bottom = 32.dp)
.fillMaxWidth()
)
Text(
text = stringResource(R.string.tip_amount, tip),
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.height(150.dp))
}
}
- Ejecuta la app en un emulador o dispositivo y, luego, ingresa un importe para la factura y el porcentaje de propina. ¿La app calcula el importe correcto para la propina?
5. Establece un botón de acción
En el codelab anterior, exploraste cómo usar la clase KeyboardOptions
para establecer el tipo de teclado. En esta sección, aprenderás a configurar el botón de acción del teclado con el mismo KeyboardOptions
. El botón de acción de teclado es el que se encuentra al final del teclado. Puedes ver algunos ejemplos en esta tabla:
Propiedad | Botón de acción del teclado |
| |
| |
|
En esta tarea, establecerás dos botones de acción diferentes para los cuadros de texto:
- Un botón de acción Next para el cuadro de texto Bill Amount, que indica que el usuario ya completó la entrada actual y quiere pasar al siguiente cuadro de texto
- Un botón de acción Done para el cuadro de texto Tip Percentage, que indica que el usuario terminó de proporcionar la entrada
Puedes ver ejemplos de teclados con estos botones de acción en las siguientes imágenes:
Agrega opciones del teclado:
- En la llamada a función
TextField()
de la funciónEditNumberField()
, pasa el constructorKeyboardOptions
, un argumento con nombreimeAction
establecido en un valorImeAction.Next
. Usa la funciónKeyboardOptions.Default.copy()
para asegurarte de usar las otras opciones predeterminadas.
import androidx.compose.ui.text.input.ImeAction
@Composable
fun EditNumberField(
//...
) {
TextField(
//...
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
)
)
}
- Ejecuta la app en un emulador o dispositivo. El teclado ahora muestra el botón de acción Next (Siguiente), como se puede ver en esta imagen:
Observa que el teclado muestra el mismo botón de acción Next (Siguiente) cuando se selecciona el campo de texto Tip Percentage (Porcentaje de propina). Sin embargo, te conviene usar dos botones de acción diferentes para los campos de texto. En breve, solucionarás este problema.
- Examina la función
EditNumberField()
. El parámetrokeyboardOptions
de la funciónTextField()
está codificado. Si deseas crear botones de acción diferentes para los campos de texto, debes pasar el objetoKeyboardOptions
como argumento, y lo harás en el paso siguiente.
// No need to copy, just examine the code.
fun EditNumberField(
@StringRes label: Int,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
//...
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
)
)
}
- En la definición de la función
EditNumberField()
, agrega un parámetrokeyboardOptions
de tipoKeyboardOptions
. En el cuerpo de la función, asigna el parámetro con nombrekeyboardOptions
de la funciónTextField()
:
@Composable
fun EditNumberField(
@StringRes label: Int,
keyboardOptions: KeyboardOptions,
// ...
){
TextField(
//...
keyboardOptions = keyboardOptions
)
}
- En la función
TipTimeLayout()
, actualiza la primera llamada a funciónEditNumberField()
y pasa el parámetro con nombrekeyboardOptions
para el campo de texto Bill amount:
EditNumberField(
label = R.string.bill_amount,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
// ...
)
- En la segunda llamada a función
EditNumberField()
, cambia elimeAction
del campo de texto Tip Percentage aImeAction.Done
. Tu función debería verse como este fragmento de código:
EditNumberField(
label = R.string.how_was_the_service,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
// ...
)
- Ejecuta la app. Se mostrarán los botones de acción Next y Done, como se puede ver en estas imágenes:
- Ingresa un importe de factura y haz clic en el botón de acción Next (Siguiente). Luego, ingresa un porcentaje de propina y haz clic en el botón de acción Done (Listo). Eso cerrará el teclado.
6. Agrega un interruptor
Un interruptor activa o desactiva el estado de un único elemento.
Existen dos estados en un interruptor que permiten al usuario seleccionar entre dos opciones. Un interruptor consiste en una barra, un círculo y un ícono opcional, como se puede ver en estas imágenes:
El interruptor es un control de selección que se puede usar para ingresar decisiones o declarar preferencias, como los parámetros que se pueden ver en estas imágenes:
El usuario puede arrastrar el círculo hacia adelante y hacia atrás para elegir la opción seleccionada, o simplemente presionar el botón de interruptor. Puedes ver otro ejemplo de un interruptor en este GIF, en el que la configuración de Visual options se activa a Modo oscuro:
Para obtener más información, consulta la documentación sobre interruptores.
Usas el elemento componible Switch
para que el usuario pueda elegir si redondea la propina al número entero más cercano como se muestra en la siguiente imagen:
Agrega una fila para los elementos que Text
y Switch
componibles:
- Después de la función
EditNumberField()
, agrega una función de componibilidadRoundTheTipRow()
y pasa unModifier
predeterminado, como argumentos similares a la funciónEditNumberField()
:
@Composable
fun RoundTheTipRow(modifier: Modifier = Modifier) {
}
- Implementa la función
RoundTheTipRow()
, agrega un elemento de diseño componibleRow
con el siguientemodifier
para establecer el ancho de los elementos secundarios al máximo en la pantalla, centrar la alineación y garantizar un tamaño de48dp
:
Row(
modifier = modifier
.fillMaxWidth()
.size(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
}
- Importa lo siguiente:
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
- En el bloque de lambda del elemento de componibilidad de diseño
Row
, agrega un elemento de componibilidadText
y usa el recurso de stringsR.string.round_up_tip
para mostrar una stringRound up tip?
:
Text(text = stringResource(R.string.round_up_tip))
- Después del elemento
Text
componible, agrega un elementoSwitch
también componible, pasa un parámetro con nombrechecked
establecido enroundUp
y un parámetro con nombreonCheckedChange
establecido enonRoundUpChanged
.
Switch(
checked = roundUp,
onCheckedChange = onRoundUpChanged,
)
Esta tabla contiene información sobre estos parámetros, que son los mismos que definiste para la función RoundTheTipRow()
:
Parámetro | Descripción |
| Indica si el interruptor está marcado. Este es el estado del elemento |
| Es la devolución de llamada a la que se llamará cuando se haga clic en el interruptor. |
- Importa lo siguiente:
import androidx.compose.material3.Switch
- En la función
RoundTheTipRow()
, agrega un parámetroroundUp
de tipoBoolean
y una función lambdaonRoundUpChanged
que tome unBoolean
, pero no muestre nada:
@Composable
fun RoundTheTipRow(
roundUp: Boolean,
onRoundUpChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier
)
Con esta acción, se eleva el estado del interruptor.
- En el elemento de componibilidad
Switch
, agrega estemodifier
para alinear el elemento de componibilidadSwitch
al final de la pantalla:
Switch(
modifier = modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
//...
)
- Importa lo siguiente:
import androidx.compose.foundation.layout.wrapContentWidth
- En la función
TipTimeLayout()
, agrega una variable var para el estado del elemento componibleSwitch
. Crea una variablevar
llamadaroundUp
y establécela enmutableStateOf()
, confalse
como valor inicial. Rodea la llamada conremember { }
.
fun TipTimeLayout() {
//...
var roundUp by remember { mutableStateOf(false) }
//...
Column(
...
) {
//...
}
}
Esta es la variable del estado componible Switch
y el valor falso será el predeterminado.
- En el bloque
Column
de la funciónTipTimeLayout()
, después del campo de texto Tip Percentage, llama a la funciónRoundTheTipRow()
con los siguientes argumentos: un parámetro con nombreroundUp
establecido enroundUp
y un parámetro con nombreonRoundUpChanged
establecido en una devolución de llamada lambda que actualice el valorroundUp
:
@Composable
fun TipTimeLayout() {
//...
Column(
...
) {
Text(
...
)
Spacer(...)
EditNumberField(
...
)
EditNumberField(
...
)
RoundTheTipRow(
roundUp = roundUp,
onRoundUpChanged = { roundUp = it },
modifier = Modifier.padding(bottom = 32.dp)
)
Text(
...
)
}
}
Se mostrará la fila Round up tip?.
- Ejecuta la app. Esta mostrará el botón de activación Round up tip? (¿Quieres redondear la propina?).
- Ingresa un importe de la factura y un porcentaje de propina y selecciona el botón de activación Round up tip? (¿Quieres redondear la propina?). El importe de la propina no se redondea porque todavía necesitas actualizar la función
calculateTip()
; lo haremos en la siguiente sección.
Actualiza la función calculateTip()
para redondear la propina.
Modifica la función calculateTip()
de modo que acepte una variable Boolean
para redondear la propina al número entero más cercano:
- Para redondear la propina, la función
calculateTip()
debe conocer el estado del interruptor, que es unBoolean
. En la funcióncalculateTip()
, agrega un parámetroroundUp
de tipoBoolean
:
private fun calculateTip(
amount: Double,
tipPercent: Double = 15.0,
roundUp: Boolean
): String {
//...
}
- En la función
calculateTip()
, antes de la sentenciareturn
, agrega una condiciónif()
que verifique el valorroundUp
. SiroundUp
estrue
, define una variabletip
, establécela enkotlin.math.
ceil
()
y pasa la funcióntip
como argumento:
if (roundUp) {
tip = kotlin.math.ceil(tip)
}
La función calculateTip()
completa debería verse como este fragmento de código:
private fun calculateTip(amount: Double, tipPercent: Double = 15.0, roundUp: Boolean): String {
var tip = tipPercent / 100 * amount
if (roundUp) {
tip = kotlin.math.ceil(tip)
}
return NumberFormat.getCurrencyInstance().format(tip)
}
- En la función
TipTimeLayout()
, actualiza la llamada a la funcióncalculateTip()
y pasa un parámetroroundUp
:
val tip = calculateTip(amount, tipPercent, roundUp)
- Ejecuta la app. Ahora, esta redondea el importe de la propina, como puedes ver en las siguientes imágenes:
7. Agrega compatibilidad con la orientación horizontal
Los dispositivos Android están disponibles en una variedad de factores de forma, como teléfonos, tablets, plegables y dispositivos ChromeOS, que incluyen una gran gama de tamaños de pantalla. La app debe admitir tanto la orientación vertical como la horizontal.
- Prueba la app en modo horizontal y activa el Giro automático.
- Rota el emulador o dispositivo hacia la izquierda. Observa que no puedes ver el importe de la propina. Para resolver este problema, necesitarás una barra de desplazamiento vertical que te ayude a desplazarte por la pantalla de la app.
- Agrega
.verticalScroll(rememberScrollState())
al modificador para permitir que la columna se desplace verticalmente. La funciónrememberScrollState()
crea y recuerda automáticamente el estado de desplazamiento.
@Composable
fun TipTimeLayout() {
// ...
Column(
modifier = Modifier
.padding(40.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
//...
}
}
- Importa lo siguiente:
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
- Vuelve a ejecutar la app. Intenta desplazarte en el modo horizontal.
8. Agrega un ícono inicial a los campos de texto (opcional)
Los íconos pueden hacer que el campo de texto resulte más atractivo visualmente y proporcionar información adicional sobre él. Los íconos se pueden usar para transmitir información sobre el propósito del campo de texto, como qué tipo de datos se esperan o qué tipo de entrada es necesaria. Por ejemplo, el ícono de un teléfono junto a un campo de texto podría indicar que se espera que el usuario ingrese un número de teléfono.
Los íconos se pueden usar para guiar la entrada del usuario proporcionando indicios visuales sobre lo que se espera. Por ejemplo, el ícono de un calendario junto a un campo de texto podría indicar que se espera que el usuario ingrese una fecha.
A continuación, se muestra un ejemplo de un campo de texto con un ícono de búsqueda que indica que se debe ingresar el término de búsqueda.
Agrega otro parámetro al elemento componible EditNumberField()
llamado leadingIcon
del tipo Int
. Anótalo con @DrawableRes
.
@Composable
fun EditNumberField(
@StringRes label: Int,
@DrawableRes leadingIcon: Int,
keyboardOptions: KeyboardOptions,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier
)
- Importa lo siguiente:
import androidx.annotation.DrawableRes
import androidx.compose.material3.Icon
- Agrega el ícono inicial al campo de texto.
leadingIcon
toma un elemento componible, por lo que pasarás el siguiente elementoIcon
de este tipo.
TextField(
value = value,
leadingIcon = { Icon(painter = painterResource(id = leadingIcon), null) },
//...
)
- Pasa el ícono inicial a los campos de texto. Para tu conveniencia, los íconos ya están presentes en el código de partida.
EditNumberField(
label = R.string.bill_amount,
leadingIcon = R.drawable.money,
// Other arguments
)
EditNumberField(
label = R.string.how_was_the_service,
leadingIcon = R.drawable.percent,
// Other arguments
)
- Ejecuta la app.
¡Felicitaciones! Ahora tu app puede calcular propinas personalizadas.
9. Obtén el código de la solución
Para descargar el código del codelab terminado, puedes usar este comando de git:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git
También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.
Si deseas ver el código de la solución, puedes hacerlo en GitHub.
10. Conclusión
¡Felicitaciones! Agregaste la funcionalidad de propina personalizada a tu app de Tip Time. Ahora, la app permite que los usuarios ingresen un porcentaje de propina personalizado y redondeen el importe correspondiente. Comparte tu trabajo en redes sociales con el hashtag #AndroidBasics.