How to implement a thousands separator visual transformation which also works with decimals too. I have found an implementation of thousand separator visual transformation for Int numbers but the problem is when I want to use it for decimal numbers which I have to control the count of decimal separator not to exceed more than 1 time.
Asked
Active
Viewed 1,183 times
2 Answers
5
You can use:
onValueChange
attribute to restrict the allowed character to a decimal number using a regex patternvisualTransformation
to format the number with the thousands separators
Something like:
val pattern = remember { Regex("^\\d*\\.?\\d*\$") }
TextField(
value = text,
onValueChange = {
if (it.isEmpty() || it.matches(pattern)) {
text = it
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
visualTransformation = ThousandSeparatorTransformation()
)
class ThousandSeparatorTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val symbols = DecimalFormat().decimalFormatSymbols
val decimalSeparator = symbols.decimalSeparator
var outputText = ""
var integerPart = 0L
var decimalPart = ""
if (text.text.isNotEmpty()) {
val number = text.text.toDouble()
integerPart = number.toLong()
outputText += NumberFormat.getIntegerInstance().format(integerPart)
if (text.text.contains(decimalSeparator)) {
decimalPart = text.text.substring(text.text.indexOf(decimalSeparator))
if (decimalPart.isNotEmpty()) {
outputText += decimalPart
}
}
}
val numberOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return outputText.length
}
override fun transformedToOriginal(offset: Int): Int {
return text.length
}
}
return TransformedText(
text = AnnotatedString(outputText),
offsetMapping = numberOffsetTranslator
)
}
}
With this OffsetMapping
the cursor remains stationary at the end of the value. Otherwise you have to calculate the thousandsSeparatorCount and fix the offset according to it.

Gabriele Mariotti
- 320,139
- 94
- 887
- 841
-
This method disables manual cursor selection. Is there a workaround for this? – geft Dec 02 '22 at 05:24
-
Seems to be a bug on Compose https://issuetracker.google.com/issues/240220202 fixed in 1.3.0-beta01 – geft Dec 02 '22 at 05:38
-
@geft It is an expected result. As explained in the answer: With this OffsetMapping the cursor remains stationary at the end of the value. Otherwise you have to calculate the thousandsSeparatorCount and fix the offset according to it. – Gabriele Mariotti Dec 02 '22 at 06:08
-
2Got it, For future reference, this is the code snippet if you want to enable cursor dragging. Just minus it for the inverse. `offset + countThousandSeparators(outputText.take(offset + 1))` – geft Dec 02 '22 at 08:01
-
1@geft could you please provide the full code that enables cursor dragging? – Reza Faraji Mar 06 '23 at 08:52
4
Decimal Amount Visual Transformation - Jetpack compose Visual Transformation for decimal input. Cursor works well!
DecimalAmountTransformation
private val groupingSymbol = ' '
private val decimalSymbol = '.'
private val numberFormatter: DecimalFormat = DecimalFormat("#,###").apply {
decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply {
groupingSeparator = groupingSymbol
decimalSeparator = decimalSymbol
}
}
class DecimalAmountTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val transformation = reformat(text.text)
return TransformedText(
AnnotatedString(transformation.formatted ?: ""),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return transformation.originalToTransformed[offset]
}
override fun transformedToOriginal(offset: Int): Int {
return transformation.transformedToOriginal[offset]
}
},
)
}
private fun reformat(original: String): Transformation {
val parts = original.split(decimalSymbol)
check(parts.size < 3) { "original text must have only one dot (use filteredDecimalText)" }
val hasEndDot = original.endsWith('.')
var formatted = original
Log.d("original_tag", original)
if (original.isNotEmpty() && parts.size == 1) {
formatted = numberFormatter.format(BigDecimal(parts[0]))
if (hasEndDot) {
formatted += decimalSymbol
}
} else if (parts.size == 2) {
val numberPart = numberFormatter.format(BigDecimal(parts[0]))
val decimalPart = parts[1]
formatted = "$numberPart.$decimalPart"
}
val originalToTransformed = mutableListOf<Int>()
val transformedToOriginal = mutableListOf<Int>()
var specialCharsCount = 0
formatted.forEachIndexed { index, char ->
if (groupingSymbol == char) {
specialCharsCount++
} else {
originalToTransformed.add(index)
}
transformedToOriginal.add(index - specialCharsCount)
}
originalToTransformed.add(originalToTransformed.maxOrNull()?.plus(1) ?: 0)
transformedToOriginal.add(transformedToOriginal.maxOrNull()?.plus(1) ?: 0)
return Transformation(formatted, originalToTransformed, transformedToOriginal)
}
}
data class Transformation(
val formatted: String?,
val originalToTransformed: List<Int>,
val transformedToOriginal: List<Int>,
)
We need to filter input as well to achive the needed result DecimalnputFilter.kt
private val decimalSymbol = '.'
object InputFilterRegex {
val DecimalInput by lazy { Regex("^(\\d*\\.?)+\$") }
}
fun filteredDecimalText(input: TextFieldValue): TextFieldValue {
var inputText = input.text.replaceFirst(regex = Regex("^0+(?!$)"), "")
var startsWithDot = input.text.startsWith(decimalSymbol)
var selectionStart = input.selection.start
var selectionEnd = input.selection.end
if (startsWithDot) {
inputText = "0$inputText"
if (selectionStart == selectionEnd) {
selectionStart++
selectionEnd++
} else {
selectionEnd++
}
}
val parts = inputText.split(decimalSymbol)
var text = if (parts.size > 1) {
parts[0] + decimalSymbol + parts.subList(1, parts.size).joinToString("")
} else {
parts.joinToString("")
}
if (text.startsWith(decimalSymbol)) {
text = "0$text"
}
return input.copy(text = text, selection = TextRange(selectionStart, selectionEnd))
}
Finally, usage will look like this:
BasicTextField(
value = value,
onValueChange = {
if (!it.text.contains(InputFilterRegex.DecimalInput)) {
return@BasicTextField
}
onValueChange(filteredDecimalText(it))
},
visualTransformation = DecimalAmountTransformation(),
)

Sardor Islomov
- 41
- 2
-
-
@DaniilPozdnyakov Hi, yes my bad. I've updated the answer and gist. It is just data class which keeps needed data inside – Sardor Islomov Mar 24 '23 at 22:16