Skip to content

Instantly share code, notes, and snippets.

@momvart
Last active April 9, 2022 09:41
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save momvart/5e756f227ff3a813b5870260453fdb73 to your computer and use it in GitHub Desktop.
Save momvart/5e756f227ff3a813b5870260453fdb73 to your computer and use it in GitHub Desktop.
A VisualTransformation for inserting thousand-separator commas in TextFields inputting numbers. It also has support for fraction parts and can put limit on the number of fraction digits. #android #compose
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 java.text.DecimalFormat
import kotlin.math.min
class ThousandSeparatorVisualTransformation(
var maxFractionDigits: Int = Int.MAX_VALUE,
var minFractionDigits: Int = 0
) : VisualTransformation {
/* 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 {
if (text.isEmpty())
return TransformedText(text, OffsetMapping.Identity)
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",
text.spanStyles,
text.paragraphStyles
)
val offsetMapping = ThousandSeparatorOffsetMapping(
intPart.length,
integersWithComma.length,
newText.length,
integersWithComma.indices.filter { integersWithComma[it] == comma }.asSequence(),
normalizedIntPart != intPart
)
return TransformedText(newText, offsetMapping)
}
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)
transformedLength
else
offset + commaCount + leadingZeroOffset
else
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
else
offset - commaIndices.takeWhile { it <= offset }.count()
private fun calcCommaCount(intDigitCount: Int) =
max((intDigitCount - 1) / 3, 0)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment