First, the composable for a text field is androidx.compose.material.TextField.
Given that only numbers will be allowed, choose the Number Keyboard Type with:
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
),
You can restrict the wrong inputs in onValueChange function closure:
var textState by remember { mutableStateOf("") }
.....
onValueChange = { newText: String ->
if (newText.isDigitsOnly() &&
conditionMinValue(newText) &&
conditionMaxValue(newText) && etc...) {
textState = newText
}
},
Although, the changing of text in onValueChanged callback for the visual part is not recommended because the text state is shared with out process IME (software keyboard) and then you would have to handle errors with the keyboard.
You need to implement your own Visual transformation class for the visual part
visualTransformation = NumberCommaTransformation(),
You can read: https://blog.shreyaspatil.dev/filtering-and-modifying-text-input-in-jetpack-compose-way
and: How to format number with comma in TextField in compose
See you.
UPDATE
I delete the first Update and I left the last code
UPDATE 2nd
My code IS:
package com.intecanar.candida.ui.newbrand.product.components
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import java.text.DecimalFormat
import kotlin.math.max
fun priceValueToStringFloat(price: Float): String {
//from float to "floatString"
return price.toString()
.replace(".", "")
.trimStart('0')
}
fun myAmountInputToFloat(amount: String): Float {
val price = 0.0f
if (amount.isEmpty()) {
return price
}
val symbols = DecimalFormat().decimalFormatSymbols
val decimalSeparator = symbols.decimalSeparator
val integerPart: String = if (amount.length > 2) {
amount.subSequence(0, amount.length - 2).toString()
} else {
"0"
}
var fractionPart: String = if (amount.length >= 2) {
amount.subSequence(amount.length - 2, amount.length).toString()
} else {
amount
}
// Add zeros if the fraction part length is not 2
if (fractionPart.length < 2) {
fractionPart = fractionPart.padStart(2, '0')
}
return (
integerPart + decimalSeparator + fractionPart
).toFloat()
}
class NumberCommaTransformation(val showCurrency: Boolean = false) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val (integerPart, newAnnotatedText) = formatText(text, showCurrency)
val offsetMapping = ThousandSeparatorOffsetMapping(
originalIntegerLength = integerPart.length,
showCurrency = showCurrency,
originalTextLength = text.length
)
return TransformedText(
text = newAnnotatedText,
offsetMapping = offsetMapping
)
}
private fun formatText(
text: AnnotatedString,
showCurrency: Boolean
): Pair<CharSequence, AnnotatedString> {
val symbols = DecimalFormat().decimalFormatSymbols
val thousandsSeparator = symbols.groupingSeparator
val decimalSeparator = symbols.decimalSeparator
val currencySymbol = symbols.currencySymbol
val originalText: String = text.text
val integerPart = if (originalText.length > 2) {
originalText.subSequence(0, originalText.length - 2)
} else {
"0"
}
var fractionPart = if (originalText.length >= 2) {
originalText.subSequence(originalText.length - 2, originalText.length)
} else {
originalText
}
// Add zeros if the fraction part length is not 2
if (fractionPart.length < 2) {
fractionPart = fractionPart.padStart(2, '0')
}
val thousandsReplacementPattern = Regex("\\B(?=(?:\\d{3})+(?!\\d))")
val formattedIntWithThousandsSeparator =
integerPart.replace(
thousandsReplacementPattern,
thousandsSeparator.toString()
)
val formattedText = if (showCurrency) {
currencySymbol + " " +
formattedIntWithThousandsSeparator + decimalSeparator + fractionPart
} else {
formattedIntWithThousandsSeparator + decimalSeparator + fractionPart
}
val newAnnotatedText = AnnotatedString(
formattedText,
text.spanStyles,
text.paragraphStyles
)
return Pair(integerPart, newAnnotatedText)
}
}
class ThousandSeparatorOffsetMapping(
val originalIntegerLength: Int, val showCurrency: Boolean, val originalTextLength: Int
) : OffsetMapping {
private val currencyOffset: Int = if (showCurrency) 2 else 0 //currencySymbol + " "
private val defaultValueOffset: Int = 4 // "0.00"
private val decimalSeparatorOffset: Int = 1 // "."
//counting the extra characters shows after transformation
/**
* It is important to understand that we prefer for our cursor to remain stationary at
* the end of the sum. So, if the input is empty, or we just inserted two digits,
* our output string will always have minimum 4 characters (“0.00”),
* that’s why we fixed originalToTransformed offset 0, 1, 2 -> 4.
* */
//https://medium.com/google-developer-experts/
// hands-on-jetpack-compose-visualtransformation-to-create-a-phone-number-formatter-99b0347fc4f6
//https://medium.com/@banmarkovic/
// how-to-create-currency-amount-input-in-android-jetpack-compose-1bd11ba3b629
//https://developer.android.com/reference/kotlin/
// androidx/compose/ui/text/input/VisualTransformation
override fun originalToTransformed(offset: Int): Int =
when (offset) {
0, 1, 2 -> currencyOffset + defaultValueOffset
else -> currencyOffset + offset + decimalSeparatorOffset + calculateThousandsSeparatorCount(
originalIntegerLength
)
}
/**
* it must return a value between 0 and the original length
* or it will return an exception
* //es el offset para que siempre este al final
* */
override fun transformedToOriginal(offset: Int): Int {
var calculated = originalIntegerLength +
calculateThousandsSeparatorCount(originalIntegerLength) + 2
if (originalTextLength < calculated) {
calculated = originalTextLength
}
if (calculated < 0) {
calculated = 0
}
return calculated
}
private fun calculateThousandsSeparatorCount(
intDigitCount: Int
) = max((intDigitCount - 1) / 3, 0)
}
And the input:
package com.intecanar.candida.ui.newbrand.product.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.core.text.isDigitsOnly
import com.intecanar.candida.R
import com.intecanar.candida.ui.theme.CandidaTheme
import com.intecanar.candida.ui.theme.Price
@Composable
fun MyAmountInput(
modifier: Modifier = Modifier,
initialPrice: Float = 0.0f,
priceState: MutableState<String> = rememberSaveable {
mutableStateOf( priceValueToStringFloat(initialPrice) )
},
caption: String, maxInput: Int,
captionFontSize: TextUnit = 20.sp,
innerTextFontSize: TextUnit = 25.sp,
maxLengthFontSize: TextUnit = 15.sp,
notifyNewValue: (Float) -> Unit,
) {
Column(modifier = modifier) {
val colors = MaterialTheme.colors
val numberCommaTransformation = remember {
NumberCommaTransformation(showCurrency = true)
}
val visualTransformation by remember(priceState.value) {
mutableStateOf(
if (priceState.value.isBlank()) {
VisualTransformation.None
} else {
numberCommaTransformation
}
)
}
val primaryColor = colors.primary
val alphaColor = 0.12f
val primaryOpaque = colors.primary.copy(alpha = alphaColor)
Text(
text = caption,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
textAlign = TextAlign.Start,
color = primaryColor,
fontSize = captionFontSize,
fontWeight = FontWeight.W600,
)
TextField(
modifier = Modifier.fillMaxWidth().testTag("TextField $caption"),
value = priceState.value,
colors = TextFieldDefaults.textFieldColors(
backgroundColor = primaryOpaque,
cursorColor = primaryColor,
disabledLabelColor = primaryOpaque,
//hide the indicator
focusedIndicatorColor = Color.Transparent,
//bottom line color
unfocusedIndicatorColor = Color.Transparent
),
onValueChange = { newPrice: String ->
if (newPrice.length <= maxInput) {
if (newPrice.isDigitsOnly()) {
if (newPrice.startsWith("0")) {
priceState.value = ""
} else {
priceState.value = newPrice
}
notifyNewValue(
myAmountInputToFloat(priceState.value)
)
} else {
//Log.i("AMOUNT", "2BBBBB")
}
}
},
shape = RoundedCornerShape(8.dp),
singleLine = true,
trailingIcon = {
if (priceState.value.isNotEmpty()) {
IconButton(onClick = {
priceState.value = ""
notifyNewValue(0.0f)
}, modifier = Modifier.testTag("X IconButton $caption")) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(id = R.string.cd_close_text)
)
}
}
},
textStyle = TextStyle(
fontFamily = Price,
fontSize = innerTextFontSize,
fontWeight = FontWeight.W400,
color = primaryColor
),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.NumberPassword,
),
//Only transform the visual part do not affect the textState
visualTransformation = visualTransformation,
)
Text(
text = "${priceState.value.length} / $maxInput",
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp).testTag("Remaining Text $caption"),
textAlign = TextAlign.End,
fontWeight = FontWeight.W600,
color = primaryColor,
fontFamily = Price,
fontSize = maxLengthFontSize,
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MyAmountInputPreview() {
CandidaTheme {
Column(
Modifier
.background(color = MaterialTheme.colors.background) //OK
) {
MyAmountInput(
caption = "Amount",
initialPrice = 45484.13f, maxInput = 110,
notifyNewValue = { }
)
}
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MyAmountInputEmptyPreview() {
CandidaTheme {
Column(
Modifier
.background(color = MaterialTheme.colors.background) //OK
) {
MyAmountInput(
caption = "Amount",
initialPrice = 0.0f, maxInput = 110,
notifyNewValue = { }
)
}
}
}