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) } } }
Job Offers
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!
This article was previously published on proandroiddev.com