3

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.

Implementation link

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
Reza Faraji
  • 435
  • 1
  • 7
  • 14

2 Answers2

5

You can use:

  • onValueChange attribute to restrict the allowed character to a decimal number using a regex pattern
  • visualTransformation 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.

enter image description here

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
  • 2
    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))` – 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(),
)

Gist here