Jetpack compose thousands separator visual transformation that also works with a decimal
Asked Answered
P

3

5

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

Portuguese answered 23/11, 2022 at 12:19 Comment(0)
I
7

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

Invalidism answered 23/11, 2022 at 20:50 Comment(6)
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-beta01Septimal
@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
E
6

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

Excursionist answered 24/3, 2023 at 9:59 Comment(2)
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 insideExcursionist
E
2

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)
    }
}

Execute answered 23/1 at 16:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.