Calculate a custom tip

1. Before you begin

In this codelab, you use the solution code from the Intro to state in Compose codelab to build an interactive tip calculator that can automatically calculate and round a tip amount when you enter the bill amount and tip percentage. You can see the final app in this image:

24370de6d667a700.png

Prerequisites

  • The Use state in Jetpack Compose codelab
  • Ability to add Text and TextField composables to an app.
  • Knowledge of the remember function, state, state hoisting, and the difference between stateful and stateless composable functions

What you'll learn

  • How to add an action button to a virtual keyboard.
  • How to set up keyboard actions.
  • What a Switch composable is and how to use it.
  • What the Layout Inspector is.

What you'll build

  • A Tip Time App that calculates tip amounts based on the user's inputted cost of service and tip percentage.

What you'll need

  • Android Studio
  • The solution code from the Use state in Jetpack Compose codelab

2. Starter app overview

This codelab begins with the Tip Time App from the previous codelab, which provides the user interface needed to calculate a tip with a fixed tip percentage. The Cost of Service text box lets the user enter the cost of the service. The app calculates and displays the tip amount in a Text composable.

Get the starter code

To get started, download the starter code:

Alternatively, you can clone the Github repository for the code:

$ 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

You can browse the code in the Tip Calculator Github repository.

Run the Tip Time App

  1. Open the Tip Time project in Android Studio and run the app on an emulator or a device.
  2. Enter a cost of service. The app automatically calculates and displays the tip amount.

761df483de663721.png

In the current implementation, the percentage of the tip is hardcoded to 15%. In this codelab, you extend this feature with a text field that lets the app calculate a custom tip percentage and round the tip amount.

Add necessary string resources

  1. In the Project tab, click res > values > strings.xml.
  2. In between the strings.xml file's <resources> tags, add these string resources:
<string name="how_was_the_service">Tip (%)</string>
<string name="round_up_tip">Round up tip?</string>

The strings.xml file should look like this code snippet, which includes the strings from the previous codelab:

strings.xml

<resources>
   <string name="app_name">TipTime</string>
   <string name="calculate_tip">Calculate Tip</string>
   <string name="cost_of_service">Cost of Service</string>
   <string name="how_was_the_service">Tip (%)</string>
   <string name="round_up_tip">Round up tip?</string>
   <string name="tip_amount">Tip Amount: %s</string>
</resources>
  1. Change the Cost Of Service string to a Bill Amount string. In some countries, service means tip, so this change prevents confusion.
  2. In the Cost of Service string, right-click the attribute's name cost_of_service and then select Refactor > Rename. A Rename dialog opens.

a2f301b95a8c0e3f.png

  1. In the Rename dialog box, replace cost_of _service with bill_amount and the click Refactor. This updates all occurrences of the cost_of_service string resource in your project, so you don't need to change the Compose code manually.

f525a371c2851d08.png

  1. In the​​ strings.xml file, change the string value to Bill Amount from Cost of Service:
<string name="bill_amount">Bill Amount</string>
  1. Navigate to the MainActivity.kt file and then run the app. The label is updated in the text box as you can see in this image:

text field displays bill amount instead of cost of service

3. Add a tip-percentage text field

A customer may want to tip more or less based on the quality of the service provided and various other reasons. To accommodate this, the app should let the user calculate a custom tip. In this section, you add a text field for the user to enter a custom tip percentage as you can see in this image:

47b5e8543e5eb754.png

You already have a Bill Amount text field composable in your app, which is the stateless EditNumberField() composable function. In the previous codelab, you hoisted the amountInput state from the EditNumberField() composable to the TipTimeScreen() function, which made the EditNumberField() composable stateless.

To add a text field, you can reuse the same EditNumberField() composable, but with a different label. To make this change, you need to pass in the label as a parameter, rather than hardcode it inside the EditNumberField() composable function.

Make the EditNumberField() composable function reusable:

  1. In the MainActivity.kt file in the EditNumberField() composable function's parameters, add a label string resource of Int type:
@Composable
fun EditNumberField(
   label: Int,
   value: String,
   onValueChange: (String) -> Unit
) 
  1. Add a modifier argument of Modifier type to the EditNumberField() composable function:
@Composable
fun EditNumberField(
   label: Int,
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) 
  1. In the function body, replace the hardcoded string resource ID with the label parameter:
@Composable
fun EditNumberField(
   //...
) {
   TextField(
       //...
       label = { Text(stringResource(label)) },
       //...
   )
}
  1. To denote that the label parameter is expected to be a string resource reference, annotate the function parameter with the @StringRes annotation:
@Composable
fun EditNumberField(
   @StringRes label: Int,
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) 
  1. Import the following:
import androidx.annotation.StringRes
  1. In the EditNumberField() function's TextField composable, pass the label parameter to the stringResource() function.
@Composable
fun EditNumberField(
   @StringRes label: Int,
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   TextField(
       //...
       label = { Text(stringResource(label)) },
       //...
   )
} 
  1. In the TipTimeScreen() function's EditNumberField() function call, set the label parameter to the R.string.bill_amount string resource:
EditNumberField(
   label = R.string.bill_amount,
   value = amountInput,
   onValueChange = { amountInput = it }
) 
  1. In the Design pane, click 2d40b921003ab5eb.png Build & Refresh. The app's UI should look like this image:

a84cd50c50235a9f.png

  1. In the TipTimeScreen() function after the EditNumberField() function call, add another text field for the custom tip percentage. Make a call to the EditNumberField() composable function with these parameters:
EditNumberField(
   label = R.string.how_was_the_service,
   value = "",
   onValueChange = { }
) 

This adds another text box for the custom tip percentage.

  1. In the Design pane, click 2d40b921003ab5eb.png Build & Refresh. The app preview now shows a Tip (%) text field as you can see in this image:

9d2c01d577d077ae.png

  1. At the top of the TipTimeScreen() function, add a var property called tipInput for the added text field's state variable. Use mutableStateOf("") to initialize the variable and surround the call with remember function:
var tipInput by remember { mutableStateOf("") }
  1. In the new EditNumberField() function call, set the value named parameter to the tipInput variable and then update the tipInput variable in the onValueChange lambda expression:
EditNumberField(
   label = R.string.how_was_the_service,
   value = tipInput, 
   onValueChange = { tipInput = it }
)
  1. In the TipTimeScreen() function after the tipInput variable's definition, define a val variable named tipPercent that converts the tipInput variable to a Double type, use an elvis operator and return 0.0 if the value is null:
val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
  1. In the TipTimeScreen() function, update the calculateTip() function call, pass in the tipPercent variable as the second parameter:
val tip = calculateTip(amount, tipPercent)

The code for the TipTimeScreen() function should look like this code snippet now:

@Composable
fun TipTimeScreen() {
   var amountInput by remember { mutableStateOf("") }
   var tipInput by remember { mutableStateOf("") }

   val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount, tipPercent)

   Column(
       modifier = Modifier.padding(32.dp),
       verticalArrangement = Arrangement.spacedBy(8.dp)
   ) {
       Text(
           text = stringResource(R.string.calculate_tip),
           fontSize = 24.sp,
           modifier = Modifier.align(Alignment.CenterHorizontally)
       )
       Spacer(Modifier.height(16.dp))
       EditNumberField(
           label = R.string.bill_amount,
           value = amountInput,
           onValueChange = { amountInput = it }
       )
       EditNumberField(
           label = R.string.how_was_the_service,
           value = tipInput,
           onValueChange = { tipInput = it }
       )
       Spacer(Modifier.height(24.dp))
       Text(
           text = stringResource(R.string.tip_amount, tip),
           modifier = Modifier.align(Alignment.CenterHorizontally),
           fontSize = 20.sp,
           fontWeight = FontWeight.Bold
       )
   }
}
  1. Run the app on an emulator or a device, and then enter a bill amount and the tip percentage. Does the app calculate tip amount correctly?

bdc482b015472300.png

4. Set an action button

In the previous codelab, you explored how to use the KeyboardOptions class to set the type of the keyboard. In this section, you explore how to set the keyboard action button with the same KeyboardOptions. A keyboard action button is a button at the end of the keyboard. You can see some examples in this table:

Property

Action button on the keyboard

ImeAction.SearchUsed when the user wants to execute a search.

ImeAction.SendUsed when the user wants to send the text in the input field.

ImeAction.GoUsed when the user wants to navigate to the target of the text in the input.

In this task, you set two different action buttons for the text boxes:

  • A Next action button for the Bill Amount text box, which indicates that the user is done with the current input and wants to move to the next text box.
  • A Done action button for the Tip % text box, which indicates that the user finished providing input.

You can see examples of keyboards with these action buttons in these images:

Add keyboard options:

  1. In the EditNumberField() function's TextField() function call, pass the KeyboardOptions constructor an imeAction named argument set to an ImeAction.Next value. Use the KeyboardOptions.Default.copy function to use the other default options like capitalization and auto correction.
@Composable
fun EditNumberField(
   //...
) {
   TextField(
       //...
       keyboardOptions = KeyboardOptions.Default.copy(
           keyboardType = KeyboardType.Number,
           imeAction = ImeAction.Next
       )
   )
}
  1. Run the app on an emulator or a device. The keyboard now displays the Next action button as you can see in this image:

However, you want two different action buttons for the text fields. You fix this problem shortly.

  1. Examine the EditNumberField() function. The keyboardOptions parameter in the TextField() function is hardcoded. To create different action buttons for the text fields, you need to pass in the KeyboardOptions object as an argument, which you will do in the next step.
// No need to copy, just examine the code.
fun EditNumberField(
   @StringRes label: Int,
   value: String,
   onValueChange: (String) -> Unit
) {
   TextField(
       //...
       keyboardOptions = KeyboardOptions.Default.copy(
          keyboardType = KeyboardType.Number,
          imeAction = ImeAction.Next
       )
   )
}
  1. In the EditNumberField() function definition, add a keyboardOptions parameter of type KeyboardOptions type. In the function body, assign it to the TextField() function's keyboardOptions named parameter:
@Composable
fun EditNumberField(
   @StringRes label: Int,
   keyboardOptions: KeyboardOptions,
   value: String,
   onValueChange: (String) -> Unit
){
   TextField(
       //...
       keyboardOptions = keyboardOptions
   )
}
  1. In the TipTimeScreen() function, update the first EditNumberField() function call, pass in the keyboardOptions named parameter for the Bill Amount text field.
EditNumberField(
   label = R.string.bill_amount,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Next
   ),
   value = amountInput,
   onValueChange = { amountInput = it }
)
  1. In the second EditNumberField() function call, change the Tip % text field's imeAction to ImeAction.Done. Your function should look like this code snippet:
EditNumberField(
   label = R.string.how_was_the_service,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Done
   ),
   value = tipInput,
   onValueChange = { tipInput = it }
)
  1. Run the app. It displays the Next and Done action buttons as you can see in these images:

  1. Enter any bill amount and click the Next action button, and then enter any tip percentage and click the Done action button. Nothing happens because you haven't added any functionality to the buttons yet. You do so in the next section.

5. Set keyboard actions

In this section, you implement the functionality to move the focus to the next text field and close the keyboard to improve the user experience with the KeyboardActions class, which lets developers specify actions that are triggered in response to users IME (Input Method editor) action on the software keyboard. An example of an IME action is when the user clicks the Next or Done action button.

You implement the following:

  • On Next action: Move the focus to the next text field (the Tip % text box).
  • On Done action: Close the virtual keyboard.
  1. In the EditNumberField() function, add a val variable named focusManager and assign it a value of LocalFocusManager.current property:
val focusManager = LocalFocusManager.current

The LocalFocusManager interface is used to control focus in Compose. You use this variable to move the focus to, and clear the focus from, the text boxes.

  1. Import import androidx.compose.ui.platform.LocalFocusManager.
  2. In the EditNumberField() function signature, add another keyboardActions parameter of KeyboardActions type:
@Composable
fun EditNumberField(
   @StringRes label: Int,
   keyboardOptions: KeyboardOptions,
   keyboardActions: KeyboardActions,
   value: String,
   onValueChange: (String) -> Unit
) {
   //...
}
  1. In the EditNumberField() function body, update the TextField() function call, set the keyboardActions parameter to the passed in keyboardActions parameter.
@Composable
fun EditNumberField(
   //...
) {
   TextField(
       //...
       keyboardActions = keyboardActions
   )
}

Now you can customize the text fields with different functionality for each action button.

  1. In the TipTimeScreen() function call, update the first EditNumberField() function call to include a keyboardActions named parameter as a new argument. Assign it a value, KeyboardActions( onNext = { } ):
// Bill amount text field
EditNumberField(
   //...
   keyboardActions = KeyboardActions(
       onNext = { }
   ),
   //...
)

The onNext named parameter's lambda expression runs when the user presses the Next action button on the keyboard.

  1. Define the lambda, request FocusManager to move the focus downwards to the next composable, Tip %. In the lambda expression, call the moveFocus() function on the focusManager object and then pass in the FocusDirection.Down argument:
// Bill amount text field
EditNumberField(
   label = R.string.bill_amount,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Next
   ),
   keyboardActions = KeyboardActions(
       onNext = { focusManager.moveFocus(FocusDirection.Down) }
   ),
   value = amountInput,
   onValueChange = { amountInput = it }
)

The moveFocus() function moves the focus in the specified direction, which is down to the Tip % text field in this case.

  1. Import the following:
import androidx.compose.ui.focus.FocusDirection
  1. Add a similar implementation to the Tip % text field. The difference is that you need to define an onDone named parameter instead of onNext.
// Tip% text field
EditNumberField(
   //...
   keyboardActions = KeyboardActions(
       onDone = { }
   ),
   //...
)
  1. After the user enters the custom tip, the Done action on the keyboard should clear the focus, which inturn closes the keyboard. Define the lambda, request FocusManager to clear the focus. In the lambda expression, call the clearFocus() function on the focusManager object:
EditNumberField(
   label = R.string.how_was_the_service,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Done
   ),
   keyboardActions = KeyboardActions(
       onDone = { focusManager.clearFocus() }),
   value = tipInput,
   onValueChange = { tipInput = it }
)

The clearFocus() function clears focus from the component that's in focus.

  1. Run the app. The keyboard actions now change the component in focus as you can see in this GIF:

3164e7a2f39a2d7b.gif

6. Add a switch

A switch toggles the state of a single item on or off. There are two-states in a toggle that let the user select between two options. A toggle consists of a thumb and a track as you can see in this images:

1. Thumb
2. Track

Switch is a selection control that can be used to enter decisions or declare preferences, such as settings as you can see in this image:

a90c4e22e48b30e0.png

The user may drag the thumb back and forth to choose the selected option, or simply tap the switch to toggle. You can see another example of a toggle in this GIF in which the Visual options setting is toggled to Dark mode:

91b7bd7a6e02e5ff.gif

To learn more about switches, see the Switches documentation.

You use the Switch composable so that the user can choose whether to round up the tip to the nearest whole number as you can see in this image:

cf89a61484296bab.png

Add a row for the Text and Switch composables:

  1. After the EditNumberField() function, add a RoundTheTipRow() composable function and then pass in a default Modifier, as arguments similar to the EditNumberField() function:
@Composable
fun RoundTheTipRow(modifier: Modifier = Modifier) {
}
  1. Implement the RoundTheTipRow() function, add a Row layout composable with the following modifier to set the child elements' width to the maximum on the screen, center the alignment, and ensure a 48 dp size:
Row(
   modifier = Modifier
       .fillMaxWidth()
       .size(48.dp),
   verticalAlignment = Alignment.CenterVertically
) {
}
  1. Import the following:
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Size
  1. In the Row layout composable's lambda block, add a Text composable that uses the R.string.round_up_tip string resource to display a Round up tip? string:
Text(text = stringResource(R.string.round_up_tip))
  1. After the Text composable, add a Switch composable, and pass a checked named parameter set it to roundUp and an onCheckedChange named parameter set it to onRoundUpChanged.
Switch(
    checked = roundUp,
    onCheckedChange = onRoundUpChanged,
)

This table contains information about these parameters, which are the same parameters that you defined for the RoundTheTipRow() function:

Parameter

Description

checked

Whether the switch is checked. This is the state of the Switch composable.

onCheckedChange

The callback to be called when the switch is clicked.

  1. Import the following:
import androidx.compose.material.Switch
  1. In the RoundTipRow() function, add a roundUp parameter of Boolean type and an onRoundUpChanged lambda function that takes a Boolean and returns nothing:
@Composable
fun RoundTheTipRow(
   roundUp: Boolean,
   onRoundUpChanged: (Boolean) -> Unit,
   modifier: Modifier = Modifier
)

This hoists the switch's state.

  1. In the Switch composable, add this modifier to align the Switch composable to the end of the screen:
       Switch(
           modifier = modifier
               .fillMaxWidth()
               .wrapContentWidth(Alignment.End),
           //...
       )
  1. Import the following:
import androidx.compose.foundation.layout.wrapContentWidth
  1. In the TipTimeScreen() function, add a var variable for the Switch composable's state. Create a var variable named roundUp set it to mutableStateOf(), with false as the default argument. Surround the call with remember { }.
fun TipTimeScreen() {
   //...
   var roundUp by remember { mutableStateOf(false) }

   //...
   Column(
       ...
   ) {
     //...
  }
}

This is the variable for the Switch composable state, and false will be the default state.

  1. In the TipTimeScreen() function's Column block after the Tip % text field, call the RoundTheTipRow() function with the following arguments: roundUp named parameter set to a roundUp and an onRoundUpChanged named parameter set to a lambda callback that updates the roundUp value:
@Composable
fun TipTimeScreen() {
   //...

   Column(
       ...
   ) {
       Text(
           ...
       )
       Spacer(...)
       EditNumberField(
           ...
       )
       EditNumberField(
           ...
       )
       RoundTheTipRow(roundUp = roundUp, onRoundUpChanged = { roundUp = it })
       Spacer(...)
       Text(
           ...
       )
   }
}

This displays the Round up tip row.

  1. Run the app. The app displays the Round up tip? toggle, but the thumb of the toggle is barely visible as you can see in this image:

Unselected and selected switches with numbers identifying its 2 elements and states1. Thumb
2. Track

You improve the thumb's visibility in the next steps by changing it to dark gray.

  1. In the RoundTheTipRow() function's Switch() composable, add a colors named parameter.
  2. Set the colors named parameter to a SwitchDefaults.colors() function that accepts an uncheckedThumbColor named parameter set to a Color.DarkGray argument.
Switch(
   //...
   colors = SwitchDefaults.colors(
       uncheckedThumbColor = Color.DarkGray
   )
)
  1. Import the following:
import androidx.compose.material.SwitchDefaults
import androidx.compose.ui.graphics.Color

The RoundTheTipRow() composable function should now look like this code snippet:

@Composable
fun RoundTheTipRow(roundUp: Boolean, onRoundUpChanged: (Boolean) -> Unit) {
   Row(
       modifier = Modifier
           .fillMaxWidth()
           .size(48.dp),
       verticalAlignment = Alignment.CenterVertically
   ) {
       Text(stringResource(R.string.round_up_tip))
       Switch(
           modifier = Modifier
               .fillMaxWidth()
               .wrapContentWidth(Alignment.End),
           checked = roundUp,
           onCheckedChange = onRoundUpChanged,
           colors = SwitchDefaults.colors(
               uncheckedThumbColor = Color.DarkGray
           )
       )
   }
}
  1. Run the app. The switch's thumb color is different as you can see in this image:

24370de6d667a700.png

  1. Enter a bill amount and tip percentage, and then select the Round up tip? toggle. The tip amount isn't rounded because you still need to update the calculateTip() function, which you do in the next section.

Update the calculateTip() function to round the tip

Modify the calculateTip() function to accept a Boolean variable to round up the tip to the nearest integer:

  1. To round up the tip, the calculateTip() function should know the state of the switch, which is a Boolean. In the calculateTip() function, add a roundUp parameter of Boolean type:
private fun calculateTip(
   amount: Double,
   tipPercent: Double = 15.0,
   roundUp: Boolean
): String { 
   //...
}
  1. In the calculateTip() function before the return statement, add an if() condition that checks the roundUp value. If the roundUp is true, define a tip variable and set to kotlin.math.ceil() function and then pass the function tip as argument:
if (roundUp)
   tip = kotlin.math.ceil(tip)

The completed calculateTip() function should look like this code snippet:

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)
}
  1. In the TipTimeScreen() function, update the calculateTip() function call and then pass in a roundUp parameter:
val tip = calculateTip(amount, tipPercent, roundUp)
  1. Run the app. Now it rounds up the tip amount as you can see in these images:

7. Get the solution code

To download the code for the finished codelab, you can use this git command:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git

Alternatively you can download the repository as a zip file, unzip it, and open it in Android Studio.

If you want to see the solution code, view it on GitHub.

8. Conclusion

Congratulations! You added custom tip functionality to your Tip Time App. Now your app lets users input a custom tip percentage and round up the tip amount. Share your work on social media with #AndroidBasics!

Learn more