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.
Jetpack compose thousands separator visual transformation that also works with a decimal
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.
This method disables manual cursor selection. Is there a workaround for this? –
Septimal
Seems to be a bug on Compose issuetracker.google.com/issues/240220202 fixed in 1.3.0-beta01 –
Septimal
@Septimal 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. –
Invalidism
Got 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))
–
Septimal @Septimal could you please provide the full code that enables cursor dragging? –
Portuguese
@Septimal for moveable cursor you check CurrencyoffsetMapping here proandroiddev.com/… –
Nadiya
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(),
)
What is Transformation class ? –
Drivein
@DaniilPozdnyakov Hi, yes my bad. I've updated the answer and gist. It is just data class which keeps needed data inside –
Excursionist
Here is my approach, cursor works fine, no casting, no regex and no too much data stored for each transformation, you also can config how the separation behave. Test and preview can see gist
internal val VisualTransformation.Companion.ThousandSeparator
@Composable
get() = remember(::ThousandSeparatorTransform)
internal class ThousandSeparatorTransform : VisualTransformation {
private val symbols = DecimalFormat().decimalFormatSymbols
override fun filter(text: AnnotatedString): TransformedText {
val string = text.text
val integer = string.substringBefore(symbols.decimalSeparator)
val output = string.withSeparator(
separator = symbols.groupingSeparator,
length = integer.length,
)
return TransformedText(
text = AnnotatedString(output),
offsetMapping = SeparatorMapping(integer.length),
)
}
}
/**
* 12345678.00 -> 12,345,678.00
* @param segmentSize length between each separator (345)
* @param length total length of the segments that need to separate (12345678)
* @param startOffset size of first segment (12)
* @param separatorCount count of separator 2 for (12,345,678.00)
* @property padding _12,345,678.00 padding is length of _
*/
private class SeparatorMapping(
private val text: String,
private val segmentSize: Int = 3,
startOffset: Int = text.lastIndex % segmentSize + 1,
separatorCount: Int = text.lastIndex / segmentSize,
) : OffsetMapping {
private val padding = segmentSize - startOffset
private val transformedTextLength = text.length + separatorCount
override fun originalToTransformed(offset: Int): Int {
val intOffset = offset.coerceAtMost(text.length)
val offsetSeparatorCount = (intOffset + padding - 1) / segmentSize
val decimalOffset = (offset - text.length).coerceAtLeast(0)
return intOffset + offsetSeparatorCount + decimalOffset
}
override fun transformedToOriginal(offset: Int): Int {
val intOffset = offset.coerceAtMost(transformedTextLength)
val offsetSeparatorCount = (intOffset + padding) / (segmentSize + 1)
val decimalOffset = (offset - transformedTextLength).coerceAtLeast(0)
return intOffset - offsetSeparatorCount + decimalOffset
}
}
/**
* 12345678.00 -> 12,345,678.00
* @param separator ","
* @param length total length of the segments that need to separate (12345678)
* @param segmentSize length between each separator (345)
* @param startOffset size of first segment (12)
* @param separatorCount count of separator 2 for (12,345,678.00)
*/
internal fun String.withSeparator(
separator: Char,
length: Int = this.length,
segmentSize: Int = 3,
startOffset: Int = (length - 1) % segmentSize + 1,
separatorCount: Int = (length - 1) / segmentSize,
) = buildString {
append(this@withSeparator)
repeat(separatorCount) { index ->
val offset = startOffset + index * (segmentSize + 1)
insert(offset, separator)
}
}
© 2022 - 2024 — McMap. All rights reserved.