When dealing with (long) decimal numbers we usually want to format them by adding proper separators so it’s easy for the users to read. Currently there is no built-in functionality in Compose to support this feature. However Compose provides us with VisualTransformation
feature for altering visual output in text fields. In this short article we will see how to use this feature to achieve proper decimal formatting.
There is a great Medium article by Ban Markovic which is inspiration for this article and covers similar situation. His solution focuses on currency formatting thus little strict on the input format. If you are dealing with formatting currency inputs please refer to Ban’s article.
Decimal Keyboard Type
Let’s start with simple input text field with keyboard type is set to decimal input.
@Composable
fun DecimalInputField(modifier: Modifier = Modifier) {
var text by remember {
mutableStateOf("")
}
OutlinedTextField(
modifier = modifier,
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal
)
)
}
As you can see there is nothing fancy going on here. One key thing we need to be aware of is that KeyboardType.Decimal
does not guarantee correct decimal input. Concretely, user can enter more than one decimal separator, random thousands separators or some other undesired inputs like so in the below image.
Thus before formatting the decimal we need to make sure that it is in the correct form. We are going to perform this clean-up process before we set the new value of the input field. To keep things tidy, I will make a class called DecimalFormatter
and write related functions in that class.
class DecimalFormatter(
symbols: DecimalFormatSymbols = DecimalFormatSymbols.getInstance()
) {
private val thousandsSeparator = symbols.groupingSeparator
private val decimalSeparator = symbols.decimalSeparator
fun cleanup(input: String): String {
if (input.matches("\\D".toRegex())) return ""
if (input.matches("0+".toRegex())) return "0"
val sb = StringBuilder()
var hasDecimalSep = false
for (char in input) {
if (char.isDigit()) {
sb.append(char)
continue
}
if (char == decimalSeparator && !hasDecimalSep && sb.isNotEmpty()) {
sb.append(char)
hasDecimalSep = true
}
}
return sb.toString()
}
}
The code is pretty self-explanatory. Let me just explain the rules we’re following here.
- If the input something non-digit then just return empty string.
- If the input is consists of consecutive zeros then return single zero.
- Allow only digit inputs with one decimal separator (in this case the first one).
Now let’s add this clean up process to our decimal input field.
@Composable
fun DecimalInputField(
modifier: Modifier = Modifier,
decimalFormatter: DecimalFormatter
) {
var text by remember {
mutableStateOf("")
}
OutlinedTextField(
modifier = modifier,
value = text,
onValueChange = {
text = decimalFormatter.cleanup(it)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal
)
)
}
VisualTransformation
As mentioned earlier VisualTransformation
is a feature that allows us to change the visual output of the input field. It is just an interface with single method called filter()
. Our original input is passed to this method and transformed one is returned. It is important to point out that VisualTransformation
does not change the input value of the field. It just alters the visual output. In other words, in the context of this article our original input value (stored in text
variable) will not have thousands separators. Let’s dive into the code.
First let’s see the code that actually inserts thousands separators to the decimal number. As you can see below formatForVisual
method handles this task and we call this method in VisualTransfromation
class. The code is pretty simple. Since input string goes through the clean-up process before being passed to formatForVisual
method we can assume that it is in the correct form. We just split the string according to the decimal separator then add thousands separators to integer part and then concatenate the new integer part and decimal part.
class DecimalFormatter(
symbols: DecimalFormatSymbols = DecimalFormatSymbols.getInstance()
) {
private val thousandsSeparator = symbols.groupingSeparator
private val decimalSeparator = symbols.decimalSeparator
fun cleanup(input: String): String {
// Refer above snippet for the implementation.
}
fun formatForVisual(input: String): String {
val split = input.split(decimalSeparator)
val intPart = split[0]
.reversed()
.chunked(3)
.joinToString(separator = thousandsSeparator.toString())
.reversed()
val fractionPart = split.getOrNull(1)
return if (fractionPart == null) intPart else intPart + decimalSeparator + fractionPart
}
}
At last, we can look into the VisualTransformation
. As you can see we implemented the interface as DecimalInputVisualTransformation
. And since we handle the formatting logic in DecimalFormatter
code the filter()
method is pretty concise. First we format the input text and make new annotated string that holds the formatted number. We make sure that the new annotated string follows style of the original input text.
Another important thing we need to consider is the position of the cursor. I think the following example would help us better understand the situation. Imagine user moves the cursor to the position after the thousands separator and clicks “delete”. Since original input doesn’t have a thousands separator this position maps to the position between digits “2” and “3”. Thus when user clicks “delete” the digit “2” would be deleted and preferably cursor should stay after the digit “1” in the original input and after the thousands separator in the visual output.
// Original input before deletion
input = 12<cursor>345.67
// Visual output before deletion
output = 12,<cursor>345.67
---
// Original input after deletion
input = 1<cursor>345.67
// Visual output after deletion
output = 1,<cursor>345.67
We handle this with OffsetMapping
interface. According to the documentation OffsetMapping
provides bidirectional offset mapping between original and transformed text. Here, to keep things simple we always want the cursor stay at the end of the text. As you can see in the FixedCursorOffsetMapping
class we just return the lengths of the texts which allows us to fix the cursor to the end.
Finally we make a TransformedText
object out formatted text and offset mapping instance and return.
class DecimalInputVisualTransformation(
private val decimalFormatter: DecimalFormatter
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val inputText = text.text
val formattedNumber = decimalFormatter.formatForVisual(inputText)
val newText = AnnotatedString(
text = formattedNumber,
spanStyles = text.spanStyles,
paragraphStyles = text.paragraphStyles
)
val offsetMapping = FixedCursorOffsetMapping(
contentLength = inputText.length,
formattedContentLength = formattedNumber.length
)
return TransformedText(newText, offsetMapping)
}
}
private class FixedCursorOffsetMapping(
private val contentLength: Int,
private val formattedContentLength: Int,
) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = formattedContentLength
override fun transformedToOriginal(offset: Int): Int = contentLength
}
Let’s put it together
@Composable
fun DecimalInputField(
modifier: Modifier = Modifier,
decimalFormatter: DecimalFormatter
) {
var text by remember {
mutableStateOf("")
}
OutlinedTextField(
modifier = modifier,
value = text,
onValueChange = {
text = decimalFormatter.cleanup(it)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
),
visualTransformation = DecimalInputVisualTransformation(decimalFormatter)
)
}
Additionally, here some unit tests for the DecimalFormatter
class.
class DecimalFormatterTest {
private val subject = DecimalFormatter(symbols = DecimalFormatSymbols(Locale.US))
@Test
fun `test cleanup decimal without fraction`() {
val inputs = arrayOf("1", "123", "123131231", "3423423")
for (input in inputs) {
val result = subject.cleanup(input)
assertEquals(input, result)
}
}
@Test
fun `test cleanup decimal with fraction normal case`() {
val inputs = arrayOf(
"1.00", "123.1", "1231.31231", "3.423423"
)
for (input in inputs) {
val result = subject.cleanup(input)
assertEquals(input, result)
}
}
@Test
fun `test cleanup decimal with fraction irregular inputs`() {
val inputs = arrayOf(
Pair("1231.12312.12312.", "1231.1231212312"),
Pair("1.12312..", "1.12312"),
Pair("...12..31.12312.123..12.", "12.311231212312"),
Pair("---1231.-.-123-12.1-2312.", "1231.1231212312"),
Pair("-.--1231.-.-123-12.1-2312.", "1231.1231212312"),
Pair("....", ""),
Pair(".-.-..-", ""),
Pair("---", ""),
Pair(".", ""),
Pair(" ", ""),
Pair(" 1231. - 12312. - 12312.", "1231.1231212312"),
Pair("1231. - 12312. - 12312. ", "1231.1231212312")
)
for ((input, expected) in inputs) {
val result = subject.cleanup(input)
assertEquals(expected, result)
}
}
@Test
fun `test formatForVisual decimal without fraction`() {
val inputs = arrayOf(
Pair("1", "1"),
Pair("12", "12"),
Pair("123", "123"),
Pair("1234", "1,234"),
Pair("12345684748049", "12,345,684,748,049"),
Pair("10000", "10,000")
)
for ((input, expected) in inputs) {
val result = subject.formatForVisual(input)
assertEquals(expected, result)
}
}
@Test
fun `test formatForVisual decimal with fraction`() {
val inputs = arrayOf(
Pair("1.0", "1.0"),
Pair("12.01723817", "12.01723817"),
Pair("123.999", "123.999"),
Pair("1234.92834928", "1,234.92834928"),
Pair("12345684748049.0", "12,345,684,748,049.0"),
Pair("10000.0009", "10,000.0009"),
Pair("0.0009", "0.0009"),
Pair("0.0", "0.0"),
Pair("0.0100008", "0.0100008"),
)
for ((input, expected) in inputs) {
val result = subject.formatForVisual(input)
assertEquals(expected, result)
}
}
}
Thank your for your attention. I hope you find this post helpful. You can reach the GitHub repo here.
The cover image is courtesy of Mika Baumeister
Top comments (3)
You good sir saved me a good day of work.
This was really helpful. Thanks
then how to force only 2 decimal places user key in.
let say old value :
1000.95
when user typing:
1000.957676677
7676677 how to be 10009576766.77?