Editing currency TextFields in Jetpack Compose

Damian Petla | Tilt
ProAndroidDev
Published in
7 min readOct 14, 2023

--

Photo by Jason Leung on Unsplash

Working on Android with currencies is fairly easy. There are built-in components that tell what is the default currency for our device, currency formatters, etc. Displaying currencies is easy. However, I have stumbled upon a challenge working on a new app feature.

I had to make a TextField that displays a price formatted with the user’s device currency. Sound easy?

Let’s list the requirements:

  • The user is allowed to enter digits only. No spaces, no special characters, no manually typed currency symbols.
  • As soon as the user types some digits, the outcome needs to be formatted as currency.

Take a look at the recording below to have a better understanding what is the goal. There are three different TextFields: first with default locale and USD currency, second with ENGLISH locale and USD currency, and third with ENGLISH locale and GBP currency.

I was typing ONLY digits on the keyboard

VisualTransfomation

TextField comes with a very handy class VisualTransformation that converts TextField String value into formatted value. One of the available transformations is PasswordVisualTransformation which turns entered values into dots. So I decided to use that!

In this article, I am going to explain how it works inside the code using comments. I believe that would be the easiest to understand. I have formatted the comments so they don’t require scrolling horizontally.

CurrencyVisualTransformation

import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.core.text.isDigitsOnly
import java.text.NumberFormat
import java.util.Currency

/**
* Visual filter for currency values. Formats values without fractions
* adding currency symbol
* based on the provided currency code and default Locale.
* @param currencyCode the ISO 4217 code of the currency
*/
private class CurrencyVisualTransformation(
currencyCode: String
) : VisualTransformation {
/**
* Currency formatter. Uses default Locale but there is an option to set
* any Locale we want e.g. NumberFormat.getCurrencyInstance(Locale.ENGLISH)
*/
private val numberFormatter = NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance(currencyCode)
maximumFractionDigits = 0
}

override fun filter(text: AnnotatedString): TransformedText {
/**
* First we need to trim typed text in case there are any spaces.
* What can by typed is also handled on TextField itself,
* see SampleUse code.
*/
val originalText = text.text.trim()
if (originalText.isEmpty()) {
/**
* If user removed the values there is nothing to format.
* Calling numberFormatter would cause exception.
* So we can return text as is without any modification.
* OffsetMapping.Identity tell system that the number
* of characters did not change.
*/
return TransformedText(text, OffsetMapping.Identity)
}
if (originalText.isDigitsOnly().not()) {
/**
* As mentioned before TextField should validate entered data
* but here we also protect the app from crashing if it doesn't
* and log warning.
* Then return same TransformedText like above.
*/
Log.w("TAG", "Currency visual transformation require using digits only but found [$originalText]")
return TransformedText(text, OffsetMapping.Identity)
}
/**
* Here is our TextField value transformation to formatted value.
* EditText operates on String so we have to change it to Int.
* It's safe at this point because we eliminated cases where
* value is empty or contains non-digits characters.
*/
val formattedText = numberFormatter.format(originalText.toInt())
/**
* CurrencyOffsetMapping is where the magic happens. See you there :)
*/
return TransformedText(
AnnotatedString(formattedText),
CurrencyOffsetMapping(originalText, formattedText)
)
}
}

/**
* Helper function prevents creating CurrencyVisualTransformation
* on every re-composition and use inspection mode
* in case you don't want to use visual filter in Previews.
* Currencies were displayed for me in Preview but I don't trust them
* so that's how you could deal with it by returning VisualTransformation.None
*/
@Composable
fun rememberCurrencyVisualTransformation(currency: String): VisualTransformation {
val inspectionMode = LocalInspectionMode.current
return remember(currency) {
if (inspectionMode) {
VisualTransformation.None
} else {
CurrencyVisualTransformation(currency)
}
}
}

CurrencyOffsetMapping

Note: CurrencyOffsetMapping translates TextField caret position between original and formatted text. By controlling the caret position we do not allow modifying currency symbols.

import androidx.compose.ui.text.input.OffsetMapping

/**
* CurrencyOffsetMapping is a class that maps offsets
* between an original text and its formatted version.
*
* @param originalText The original unformatted text.
* @param formattedText The formatted text, which has the same content
* as originalText but with different
* character positioning, due to added
* or removed formatting characters.
*/
class CurrencyOffsetMapping(originalText: String, formattedText: String) : OffsetMapping {
private val originalLength: Int = originalText.length
private val indexes = findDigitIndexes(originalText, formattedText)

/**
* Find the indexes of digits in the original text with respect
* to the formatted text.
*
* @param firstString The original unformatted text.
* @param secondString The formatted text.
* @return A list of indexes indicating the position of digits
* in the secondString (formatted text).
* The order of indexes corresponds to the order of digits
* in the original text.
* If a digit is not found in the secondString,
* an empty list is returned.
*/
private fun findDigitIndexes(firstString: String, secondString: String): List<Int> {
val digitIndexes = mutableListOf<Int>()
var currentIndex = 0
for (digit in firstString) {
// Find the index of the digit in the second string
val index = secondString.indexOf(digit, currentIndex)
if (index != -1) {
digitIndexes.add(index)
currentIndex = index + 1
} else {
// If the digit is not found, return an empty list
return emptyList()
}
}
return digitIndexes
}

/**
* Maps an offset from the original text to its corresponding position
* in the formatted text.
*
* @param offset The offset in the original text.
* @return The offset in the formatted text corresponding to the input
* offset.
* If the input offset is beyond the length of the original text,
* the last position in the formatted text is returned adding 1
* to set the caret after last digit.
*/
override fun originalToTransformed(offset: Int): Int {
/**
* Example:
* original 123
* formatted $123
* indexes [1,2,3]
* caret position/offset is 1 which is here 1|23 in the original
* in formatted text it will be offset=2 since all digits move by 1
* because of the $ symbol at start
* if caret is at the end of 123 we do not have index for it in indexes
* so we take last value from indexes and add 1
*/
if (offset >= originalLength) {
return indexes.last() + 1
}
return indexes[offset]
}

/**
* Maps an offset from the formatted text to its corresponding position
* in the original text.
*
* @param offset The offset in the formatted text.
* @return The offset in the original text corresponding to the input
* offset.
* If the input offset is beyond the length of the formatted text,
* the length of the original text is returned.
*/
override fun transformedToOriginal(offset: Int): Int {
/**
* Example 1:
* original text 123
* formatted text $123
* indexes [1, 2, 3], index 0 is taken by $ symbol
* if user tries to set caret before $ (offset = 0)
* which is not allowed
* we have to find the closest allowed caret position which in that
* case will be 1
*
* Example 2:
* original text 123
* formatted text 123 USD
* indexes [0,1,2] beyond that we have space and currency symbol
* if user tries to set caret between U and S (offset=5)
* which is not allowed
* we have to find the closest allowed caret which we cannot in indexes.
* Thus we take the length of original text to set caret after 123
*/
return indexes.indexOfFirst { it >= offset }.takeIf { it != -1 } ?: originalLength
}
}

The below images help us understand how the caret offset position is transformed in the transformedToOriginal function.

transformedToOriginal, Example 1
transformedToOriginal, Example 2

Sample use

import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.KeyboardType

private const val MAX_VALUE = 10000

/**
* Sample how to apply visual transformation and validation logic you could use
*/
@Composable
fun SampleUse() {
var value by remember { mutableStateOf("") }
val currencyVisualTransformation = rememberCurrencyVisualTransformation(currency = "USD")
TextField(
value = value,
onValueChange = { newValue ->
/**
* Trim entered value removing 0 at start and then remove
* every characters that is not a digit
* Update value only if it's empty or if value is not higher
* than 10000
*/
val trimmed = newValue.trimStart('0').trim { it.isDigit().not() }
if (trimmed.isEmpty() || trimmed.toInt() <= MAX_VALUE) {
value = trimmed
}
},
/**
* Keyboard doesn't matter much but make sense if we want
* type digits only
*/
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
/**
* Apply visual transformation here
*/
visualTransformation = currencyVisualTransformation
)
}

Here is also a short recording from the Tilt app I am working on. This custom bidding feature will be released soon. So download the app today and experience all the exciting new features for yourself!

Auction custom bidding at Tilt

Summary

I hope this article will help others understand better how visual transformation can be applied. I spent a few hours on this, so you don’t have to :)

Here is a GitHub gist without comment if you want to copy code more easily.

Again, happy coding! Cheers!

--

--