DEV Community

Mohammad Omidvar
Mohammad Omidvar

Posted on

Add Thousand-Separators for Number Inputs in Jetpack Compose


In most financial apps, numerical text fields play a significant role. People want to register their orders precisely in these input boxes, and a consistent experience is vital. One of the basic necessities for these fields is thousand-separators inserted between the numbers while the user is typing. The main challenge is usually keeping the cursor in its correct position and preventing annoying cursor jumps.

Thanks to the new VisualTransformation option added to the TextFields in Jetpack Compose, this task has gotten easier than traditional approaches using EditText. In this post, I have explained the result of migrating my custom EditText, which is meant for currencies in a cryptocurrency trader app, to the new facilities provided in Jetpack Compose.

In a nutshell, what happens in the new TextField is that it keeps the value input by the user, called original text, behind the scenes but displays a transformed version of it using the transformer. As a result, a VisualTransformation must consist of two parts:

  1. Converting the real value into the desired format that is going to be displayed.
  2. Translating each position in the original text to the transformed text and vice-versa. In this case, the component is able to place the cursor in the right position.

Both these jobs should take place in filter method, and the return value is of type TransformedText, which consists of the transformed text and the offset mapping.

A set of examples can be found in the official document, and a great set of credit card transformations is here.


Let's see how to implement a thousand-separator. In the beginning, I want to mention that as the implementation was meant to be working in a financial app, it has support for decimal places.

Adding the separators


A trivial approach would be a loop traversing the text in the reverse order and adding commas after every three characters. However, as I like regex too much, I use this pattern that selects the positions appropriate for thousand separators too.

Enter fullscreen mode Exit fullscreen mode

Let's explain this simple pattern with the help of regex101:

  • \d{3} means three digits. I'm not interested in capturing them (you will find out why), so marking it as a non-capturing group, (?:\d{3}). Therefore, (?:\d{3})+ will match groups of characters made of digits with a count that is a multitude of three.
  • (?!\d) is a negative lookahead. It matches an anchor point that is not a number, like the end of the text or a dot. Along with the previous group, it means the string of numbers with a length equal to a multitude of three and ending where the number ends. So it will not capture every triple in the middle of the number.
  • Finally, a positive lookahead matches the positions that the pattern holds true. In our case, the places to put thousand-separators.
  • \B ensures that last place is not included. We don't need a separator at the start of the number.

Converting the Integer Part

With the pattern, the rest of the work is quite straightforward. We use replace method on strings to put the commas in between.

override fun filter(text: AnnotatedString): TransformedText {
    val commaReplacementPattern = Regex("""\B(?=(?:\d{3})+(?!\d))""")
    val transformed = 
        text.text.replace(commaReplacementPattern, ",")
    // ...
Enter fullscreen mode Exit fullscreen mode

But further considerations should be given. Localization is one of the essential parts of your app if it is going to be multilingual. So, instead of directly putting a hard-coded comma, we use DecimalFormatSymbols.

val symbols = DecimalFormat().decimalFormatSymbols
val comma = symbols.groupingSeparator
Enter fullscreen mode Exit fullscreen mode
Why not using DecimalFormat?

In addition to the fact that this class requires parsing before formatting, there are some cases that it doesn't work well. For example, users may want to add several zeroes and then put a number at the left. In that case, the formatter clears all the meaningless zeroes, which deteriorates users' ​convenience.

Decimal Places

As mentioned before, in many currencies, a fraction part is available. Also, we know that this part has limited decimal places, e.g., USD can only have two decimal places. So our transformer takes two parameters, maxFractionDigits and minFractionDigits (for enforced decimal places). It ends up with this part in the code.

val zero = symbols.zeroDigit
val minFractionDigits = min(this.maxFractionDigits, this.minFractionDigits)
fracPart = fracPart.take(maxFractionDigits).padEnd(minFractionDigits, zero)
Enter fullscreen mode Exit fullscreen mode

Furthermore, there is an edge case, when the input has only a fraction part, so the user starts it with a dot. In this case, our strategy is to put a zero at the start of the text for better readability.

//Ensure there is at least one zero for integer places
val normalizedIntPart =
   if (intPart.isEmpty() && fracPart != null) zero.toString() else intPart
Enter fullscreen mode Exit fullscreen mode

All Together

Connecting the described pieces will end to the following code:

/* As android uses icu in the recent versions we just let DecimalFormat to
​* take care of the selection
private val symbols = DecimalFormat().decimalFormatSymbols

private val commaReplacementPattern = Regex("""\B(?=(?:\d{3})+(?!\d))""")

override fun filter(text: AnnotatedString): TransformedText {
   val comma = symbols.groupingSeparator
   val dot = symbols.decimalSeparator
   val zero = symbols.zeroDigit

   var (intPart, fracPart) = text.text.split(dot)
       .let { Pair(it[0], it.getOrNull(1)) }

   //Ensure there is at least one zero for integer places
   val normalizedIntPart =
       if (intPart.isEmpty() && fracPart != null) zero.toString() else intPart

   val integersWithComma = normalizedIntPart.replace(commaReplacementPattern, comma.toString())

   val minFractionDigits = min(this.maxFractionDigits, this.minFractionDigits)
   if (minFractionDigits > 0 || !fracPart.isNullOrEmpty()) {
       if (fracPart == null)
           fracPart = ""

       fracPart = fracPart.take(maxFractionDigits).padEnd(minFractionDigits, zero)

   val newText = AnnotatedString(
       integersWithComma + if (fracPart == null) "" else ".$fracPart",
  // ...
Enter fullscreen mode Exit fullscreen mode

Translation of Offsets

After the transformation is done, we need to implement two offset mapping functions described above. The main idea here is correctly counting the added characters before a position. I'm not going to the details as they are pretty straightforward, and I suffice to the code provided below.

private inner class ThousandSeparatorOffsetMapping(
   val originalIntegerLength: Int,
   val transformedIntegersLength: Int,
   val transformedLength: Int,
   val commaIndices: Sequence<Int>,
   addedLeadingZero: Boolean
) : OffsetMapping {
   val commaCount = calcCommaCount(originalIntegerLength)

   val leadingZeroOffset = if (addedLeadingZero) 1 else 0

   override fun originalToTransformed(offset: Int): Int =
       // Adding number of commas behind the character
       if (offset >= originalIntegerLength)
           if (offset - originalIntegerLength > maxFractionDigits)
               offset + commaCount + leadingZeroOffset
           offset + (commaCount - calcCommaCount(originalIntegerLength - offset))

   override fun transformedToOriginal(offset: Int): Int =
       // Subtracting number of commas behind the character
       if (offset >= transformedIntegersLength)
           min(offset - commaCount, transformedLength) - leadingZeroOffset
           offset - commaIndices.takeWhile { it <= offset }.count()

   private fun calcCommaCount(intDigitCount: Int) =
       max((intDigitCount - 1) / 3, 0)
Enter fullscreen mode Exit fullscreen mode

Final Result


Also, you can check out the full implementation.

Discussion (0)