Formatting numbers in compose TextField - android

I am trying to create a reusable NumberField component:
#Composable
fun NumberField(
value: Number?,
onNumberChange: (Number) -> Unit,
) {
TextField(
value = value?.toString() ?: "",
onValueChange = {
it.toDoubleOrNull()?.let { value ->
if (value % 1.0 == 0.0) {
onNumberChange(value.toInt())
} else {
onNumberChange(value)
}
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
To be used as:
#Composable
fun NumberContent() {
val number = remember { mutableStateOf<Number?>(null) }
NumberField(value = number.value) {
number.value = it
}
}
I would like the number to be an Int or Double depending on what the user is typing. What I have above works until you try to enter a decimal number, as it seems "5.", does not parse as double. I want to allow the user to type 5. and then fill in rest. As such I don't want to add a zero after decimal automatically because that might not be the next number they want to enter. Is this the best way to go about it? I know that I can just accept any text and then try to format the text they entered later as an int or double and make them fix it then, just thought it would be nice to bundle it all in the composable.

You can use something like:
TextField(
value = text,
onValueChange = {
if (it.isEmpty()){
text = it
} else {
text = when (it.toDoubleOrNull()) {
null -> text //old value
else -> it //new value
}
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)

Here is an implementation that handles all stated conditions while also exposing the state to parents.
#Composable
fun NumberField(
value: Number?,
onNumberChange: (Number?) -> Unit,
) {
val number = remember { mutableStateOf(value) }
val textValue = remember(value != number.value) {
number.value = value
mutableStateOf(value?.toDouble()?.let {
if (it % 1.0 == 0.0) {
it.toInt().toString()
} else {
it.toString()
}
} ?: "")
}
val numberRegex = remember { "[-]?[\\d]*[.]?[\\d]*".toRegex() }
// for no negative numbers use "[\d]*[.]?[\d]*"
TextField(
value = textValue.value,
onValueChange = {
if (numberRegex.matches(it)) {
textValue.value = it
number.value = it.toDoubleOrNull()
onNumberChange(number.value)
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
An example usage is shown below.
#Composable
fun DemoUsage() {
Column {
val number = remember { mutableStateOf<Number?>(null) }
NumberField(value = number.value) {
number.value = it
}
Button(onClick = { number.value = number.value?.toDouble()?.plus(1) }) {
Text("Increment")
}
}
}

Related

Jetpack Compose Combo box with dropdown

In order to practice Jetpack Compose I wanted to create a MultiComboBox component for later use. It's basically standard ComboBox that allows to pick multiple options. Something like below:
I did prepare a piece of code that IMO should work fine and generally it does, but there's one case when it doesn't and I cannot figure it out what's wrong.
Here's my code:
data class ComboOption(
override val text: String,
val id: Int,
) : SelectableOption
interface SelectableOption {
val text: String
}
#Composable
fun MultiComboBox(
labelText: String,
options: List<ComboOption>,
onOptionsChosen: (Set<ComboOption>) -> Unit,
modifier: Modifier = Modifier,
selectedIds: Set<Int> = emptySet(),
) {
var expanded by remember { mutableStateOf(false) }
// when no options available, I want ComboBox to be disabled
val isEnabled by rememberUpdatedState { options.isNotEmpty() }
var currentlySelected by remember(options, selectedIds) {
mutableStateOf(options.filter { it.id in selectedIds }.toSet())
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (isEnabled()) {
expanded = !expanded
if (!expanded) {
onOptionsChosen(currentlySelected)
}
}
},
modifier = modifier,
) {
val selectedSummary = when (selectedIds.size) {
0 -> ""
1 -> options.first { it.id == selectedIds.first() }.text
else -> "Wybrano ${selectedIds.size}"
}
TextField(
enabled = isEnabled(),
modifier = Modifier.menuAnchor(),
readOnly = true,
value = selectedSummary,
onValueChange = {},
label = { Text(text = labelText) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
onOptionsChosen(currentlySelected)
},
) {
for (option in options) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = option in currentlySelected,
onCheckedChange = { newCheckedState ->
if (newCheckedState) {
currentlySelected += option
} else {
currentlySelected -= option
}
},
)
Text(text = option.text)
}
},
onClick = {
val isChecked = option in currentlySelected
if (isChecked) {
currentlySelected -= option
} else {
currentlySelected += option
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}
When I pick options and then dismiss the combo by clicking somewhere outside of it - it works fine. The problem is with onExpandedChange. currentlySelected inside of that lambda is always the same as first value of selectedIds. So for example, when no options are preselected it always calls onOptionsChosen with empty set, hence regardless of what I select - it always sets empty value. Any ideas why it happens an how can it be fixed?
You can use:
#Composable
fun MultiComboBox(
labelText: String,
options: List<ComboOption>,
onOptionsChosen: (List<ComboOption>) -> Unit,
modifier: Modifier = Modifier,
selectedIds: List<Int> = emptyList(),
) {
var expanded by remember { mutableStateOf(false) }
// when no options available, I want ComboBox to be disabled
val isEnabled by rememberUpdatedState { options.isNotEmpty() }
var selectedOptionsList = remember { mutableStateListOf<Int>()}
//Initial setup of selected ids
selectedIds.forEach{
selectedOptionsList.add(it)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (isEnabled()) {
expanded = !expanded
if (!expanded) {
onOptionsChosen(options.filter { it.id in selectedOptionsList }.toList())
}
}
},
modifier = modifier,
) {
val selectedSummary = when (selectedOptionsList.size) {
0 -> ""
1 -> options.first { it.id == selectedOptionsList.first() }.text
else -> "Wybrano ${selectedOptionsList.size}"
}
TextField(
enabled = isEnabled(),
modifier = Modifier.menuAnchor(),
readOnly = true,
value = selectedSummary,
onValueChange = {},
label = { Text(text = labelText) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
onOptionsChosen(options.filter { it.id in selectedOptionsList }.toList())
},
) {
for (option in options) {
//use derivedStateOf to evaluate if it is checked
var checked = remember {
derivedStateOf{option.id in selectedOptionsList}
}.value
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = { newCheckedState ->
if (newCheckedState) {
selectedOptionsList.add(option.id)
} else {
selectedOptionsList.remove(option.id)
}
},
)
Text(text = option.text)
}
},
onClick = {
if (!checked) {
selectedOptionsList.add(option.id)
} else {
selectedOptionsList.remove(option.id)
}
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
}

TextField value is not set to empty string despite setting empty state value

My shopping list item Quantity TextField allows a decimal point, and if the user types a decimal as the first character like ".42", it throws an NumberFormatException when I cast the TextField string to a Double in the code below. To prevent the exception, I simply check to see if the TextField value is ".". If so, I set the Quantity to an empty string and display a Toast message telling the user to enter a valid quantity, but it is not setting the Quantity state to "", and pressing the decimal key at this point does not show the Toast message. I have to press the backspace key and press the decimal key again to display the message. How can I fix this?
ShoppingListItem Model
#Parcelize
data class ShoppingListItem(
val id: Long = 0L,
val shoppingListId: Long = 0L,
...
val quantity: String = "",
): Parcelable
ViewModel
private val _shoppingListItemState = MutableLiveData(
savedStateHandle.get<ShoppingListItem>("shoppinglistitem")
?: ShoppingListItem()
)
override val shoppingListItemState: LiveData<ShoppingListItem>
get() = _shoppingListItemState
...
override fun setStateValue(stateToEdit: String, stateValue: Any?) {
when (stateToEdit) {
ITEM_QUANTITY_STR -> {
_shoppingListItemState.value =
_shoppingListItemState.value!!.copy(quantity = stateValue.toString())
}
}
}
Composable
CustomOutlinedTextField(
fieldModifier = Modifier
.width(150.dp)
.padding(end = 8.dp)
.onPreviewKeyEvent {
if (it.key == Key.Tab && it.nativeKeyEvent.action == ACTION_DOWN) {
focusManager.moveFocus(FocusDirection.Right)
true
} else {
false
}
},
label = ITEM_QUANTITY_STR,
inputVal = shoppingListItemState.value!!.quantity,
isSingleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Right) }
)
) { value ->
if(value == ".") {
Toast.makeText(context, "Please enter a valid quantity", Toast.LENGTH_LONG)
.show()
mainViewModel.setStateValue(
ITEM_QUANTITY_STR,
""
)
} else {
if (value.isEmpty()) {
mainViewModel.setStateValue(
ITEM_QUANTITY_STR,
""
)
} else {
val counter = value.count { it == decimalSeparator }
if (counter <= 1) {
if (value.toDouble() <= 999_999.999) {
mainViewModel.setStateValue(
ITEM_QUANTITY_STR,
value
)
}
}
}
}
}
You can prevent the exception using in your validation something like:
value.toDoubleOrNull()
You can also use a regex to restrict the allowed character to a decimal number:
val pattern = remember { Regex("^\\d*\\.?\\d*\$") }
TextField(
value = text,
onValueChange = {
if (it.isEmpty() || it.matches(pattern)) {
text = it
validate(it.toDoubleOrNull())
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
)

Get the value from VisualTransformation Jetpack Compose TextField

How can I get the transformed value from VisualTransformation in Jetpack compose TextField? since the only thing that is changing is the visual text not the actual input/buffered text?
class CurrencyAmountInputVisualTransformation(
private val currencySymbol: String,
private val unbufferedValueChange: (String) -> Unit
) : VisualTransformation {
private val symbols = DecimalFormat().decimalFormatSymbols
private val numberOfDecimals: Int = 2
override fun filter(text: AnnotatedString): TransformedText {
val zero = symbols.zeroDigit
val inputText = text.text
val intPart = inputText
.dropLast(numberOfDecimals)
.reversed()
.chunked(3)
.joinToString(symbols.groupingSeparator.toString())
.reversed()
.ifEmpty {
zero.toString()
}
val fractionPart = inputText.takeLast(numberOfDecimals).let {
if (it.length != numberOfDecimals) {
List(numberOfDecimals - it.length) {
zero
}.joinToString("") + it
} else {
it
}
}
val formattedNumber = intPart + symbols.decimalSeparator.toString() + fractionPart
val value = inputText.dropLast(numberOfDecimals)
unbufferedValueChange("$value.$fractionPart")
val newText = AnnotatedString(
text = "$currencySymbol $formattedNumber",
spanStyles = text.spanStyles,
paragraphStyles = text.paragraphStyles
)
...
return TransformedText(newText, ...)
}
I'm getting duplicate re-composition because of this approach since I have 2 mutable state, one for the input and one for the value that I'm getting from a lambda callback I passed to the VisualTransformation.
#Composable
internal fun CurrencyField(
) {
val pattern = remember { Regex("^\\d*\\.?\\d*\$") }
var input by remember { mutableStateOf("") }
var amountInput by remember { mutableStateOf(0.00) }
OutlinedTextInputField(
text = input,
onTextChanged = {
if (it.isEmpty() || it.matches(pattern)) {
input = it
}
},
keyboardType = KeyboardType.NumberPassword,
visualTransformation = CurrencyAmountInputVisualTransformation("PHP") {
amountInput = it.toDouble()
}
)
}
Output:
Input screenshot
Is there any way I can get
232323.23
without using a callback in the VisualTransformation class?
Thanks.
You have to apply the same filter used by the VisualTransformation
var text by remember { mutableStateOf("") }
val visualTransformation = MyVisualTransformation()
TextField(
value = text,
onValueChange = { text = it },
visualTransformation = visualTransformation
)
val transformedText = remember(text, visualTransformation) {
visualTransformation.filter(AnnotatedString(text))
}.text.text

Why doesn't the edit box dialog fill in initial value when I use Jetpack Compose?

I use the following code to show a edit box dialog with initial value on which a use can input a new description and save it.
I think that the initial value "Hello" will be shown on TextField when I click the "Edit Description" button, but in fact, none is shown on TextField.
What's wrong with my code?
#Composable
fun ScreenDetail(
) {
var editDialog by remember { mutableStateOf(false) }
var description by remember {mutableStateOf("") }
editDialog(
isShow = editDialog,
onDismiss = { editDialog =false },
onConfirm ={ ... },
editFieldContent=description
)
Button(
modifier = Modifier,
onClick = {
description = "Hello"
editDialog = true
}
) {
Text("Edit Description")
}
}
#Composable
fun editDialog(
isShow: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
saveTitle: String = "Save",
cancelTitle:String = "Cancel",
dialogTitle:String ="Edit",
editFieldTitle:String ="Input description",
editFieldContent:String ="",
) {
var mText by remember { mutableStateOf(editFieldContent) }
if (isShow) {
AlertDialog(
confirmButton = {
TextButton(onClick = { onConfirm(mText) })
{ Text(text = saveTitle) }
},
dismissButton = {
TextButton(onClick = onDismiss)
{ Text(text = cancelTitle) }
},
onDismissRequest = onDismiss,
title = { Text(text = dialogTitle) },
text = {
Column() {
Text(text = editFieldTitle)
TextField(
value = mText,
onValueChange = { mText = it }
)
}
}
)
}
}
var mText by remember { mutableStateOf(editFieldContent) }
it doesn't get updated when editFieldContent changes because remember{} stores value on composition or when keys change.
Then you change mText via delegation or using mText.value = newValue if you don't use by keyword.
If you set a key, block inside remember will be recalculated when editFieldContent parameter of editDialog changes.
var mText by remember(editFieldContent) { mutableStateOf(editFieldContent) }

How to read the Semantic values of a Jetpack Compose TextField through UI tests?

I am new to Jetpack Compose testing and trying to figure out how to access the values of an OutlinedTextField to perform Instrumentation tests on them:
I can't figure out the syntax to access and check some of the values in the SemanticsNode of the EditFeild.
I am using the following Instrumentation Test:
#Test
fun NameTextField_LongInput_CompleteStatusAndLabelCorrect() {
composeTestRule.setContent {
ComposeTemplateTheme {
NameTextInput(name = "RandomName123", onNameInfoValid = { isComplete = it })
}
assertEquals(isComplete, true)
// This is accessing the label text
composeTestRule.onNodeWithText("Name").assertIsDisplayed()
//How do I access the Editable text?
//composeTestRule.onNodeWithEditableText("RandomName123") // How do I do something like this?!?!123
}
}
I would like to figure out how to access various items in this tree:
printToLog:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=110.0, r=1080.0, b=350.0)px
|-Node #2 at (l=48.0, t=158.0, r=1032.0, b=326.0)px
ImeAction = 'Default'
EditableText = 'RandomName123' // HOW DO I ACCESS THIS?!?! I want to confirm values of this?!?!
TextSelectionRange = 'TextRange(0, 0)'
Focused = 'false'
Text = '[Name]'
Actions = [GetTextLayoutResult, SetText, SetSelection, OnClick, OnLongClick, PasteText]
MergeDescendants = 'true'
Here is the complete Composable I am trying to test:
#Composable
fun NameTextInput(name: String, onNameInfoValid: (Boolean) -> Unit) {
// Name
val nameState = remember { mutableStateOf(TextFieldValue(name)) }
val nameString = stringResource(R.string.name)
val nameLabelState = remember { mutableStateOf(nameString) }
val isNameValid = if (nameState.value.text.length >= 5) {
nameLabelState.value = nameString
onNameInfoValid(true)
true
} else {
nameLabelState.value = stringResource(R.string.name_error)
onNameInfoValid(false)
false
}
OutlinedTextField(
shape = RoundedCornerShape(card_corner_radius),
value = nameState.value,
singleLine = true,
onValueChange = { if (it.text.length <= 30) nameState.value = it },
isError = !isNameValid,
label = { Text(nameLabelState.value) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words
),
colors = customTextColors(),
modifier = Modifier
.fillMaxWidth()
.padding(horizfull_verthalf)
)
}
Great question, you can access the textfield via the tag of its modifier.
OutlinedTextField(
...
modifier = Modifier
.fillMaxWidth()
.padding(horizfull_verthalf)
.testTag("field")
)
Then access the text field with above tag, you can assert the result as below:
val value = composeTestRule.onNodeWithTag("field")
value.assertTextEquals("RandomName123") // verify value of textfield
for ((key, value) in value.fetchSemanticsNode().config) {
Log.d("AAA", "$key = $value") // access and print all config
if (key.name == "EditableText"){
assertEquals("RandomName123", value.toString())
}
}
Try and test success on my side.

Categories

Resources