iamthevoid
11/26/2021, 4:08 PMOffsetMapping mapping correctly. I’ll add scratch in thread.
Logic next
When price entering then filter of VisualTransformation calls and i creating Price object. It contains three values integer(string, with spaces), fractional(string) and boolean describes is there comma or no. This object stores in my class, implements VisualTransformation and OffsetMapper. mapping of offset happens based on this object. It stores price (with spaces) and price payload. For example if price is 8 994,34 payload should be 8994,34 (witout spaces).
Based on this values i am calculating offset. I even simulate this in scratch file [screenshot] and all looks good. But in dynamic, when i interact with input - i see artifacts! i
For example: when i try to backspace number then backspaced not the number, that i want to backspace, but backspace previous, but not always
And then i understood. The problem is that offsets that i calculate is dynamic. That changes when integer length changes. Example 9 999 offset at position 2, and when i enter another digit whenever 99 989 offset now at position 3. So when i backspace digit offset positions also changes during editing.
This situation can be solved?iamthevoid
11/26/2021, 4:08 PMiamthevoid
11/26/2021, 4:09 PMval entered = "7345678"
val price = "7 345 678"
fun originalToTransformed(offset: Int): Int {
val ensureOffset = minOf(offset, entered.length)
val entered = entered.subSequence(0, minOf(ensureOffset + 1, entered.length))
var spacesCount = 0
for (i in entered.indices) {
if (entered[i] != price[i + spacesCount] && price.getOrNull(i + spacesCount + 1) != null) {
spacesCount++
}
}
return ensureOffset + spacesCount
}
fun transformedToOriginal(offset: Int): Int {
val ensureOffset = minOf(price.length, offset)
return offset - price.subSequence(0, ensureOffset + 1).trim()
.sumOf { if (it == ' ') 1L else 0L }.toInt()
}tad
11/28/2021, 3:19 AMCarl Benson
11/29/2021, 8:21 AMiamthevoid
11/29/2021, 8:24 AMfilter fun, but i should filter input in onValueChange at first. I go that way and operate with price only in Visual TransformationMayank Saini
02/10/2022, 10:26 AMiamthevoid
02/12/2022, 6:50 AMdata class AmountInput(val rubles: Int, val pennies: Penny, val hasComma: Boolean) {
val amount: Amount
get() = Amount(rubles, pennies.value ?: 0)
fun originalToTransformed(offset: Int): Int {
val transformed = format(beautify = true)
var count = 0
var index = 0
transformed.forEachIndexed { i, c ->
if (c != SPACE) count++
if (count == offset) {
index = i + 1
return@forEachIndexed
}
}
return if (offset > index) transformed.length else index
}
fun transformedToOriginal(offset: Int): Int {
val transformed = format(beautify = true)
return with(transformed.substring(0, minOf(offset, transformed.length))) {
length - count { it == SPACE }
}
}
fun format(beautify: Boolean = false): String {
return buildString {
rubles.toString().apply {
if (beautify) {
val mod = length % GROUP_RUBLES_BY
val formatted = foldRightIndexed(initial = "", { index, char, acc ->
"${if (index % GROUP_RUBLES_BY == mod) "$SPACE" else ""}$char$acc"
}).trim()
append(formatted)
} else {
append(this)
}
}
if (hasComma) append(COMMA)
with(pennies) {
if (enteredCount > 0) {
value?.also {
append(it.toString().take(enteredCount).padStart(enteredCount, ZERO))
}
}
}
}
}
companion object {
private const val SPACE = ' '
private const val COMMA = ','
private const val ZERO = '0'
private const val GROUP_RUBLES_BY = 3
private const val PENNIES_INPUT_LENGTH = 2
fun from(value: String, rublesMaxLength: Int): AmountInput {
return value.split(Regex("[.,]+"))
.let { splitties ->
val rubles = splitties.getOrNull(0)?.take(rublesMaxLength)?.toIntOrNull() ?: 0
val penniesString = splitties.getOrNull(1)?.removeLeadingExcessZeroes()
val pennies = penniesString?.take(PENNIES_INPUT_LENGTH)
?.padEnd(PENNIES_INPUT_LENGTH, ZERO)?.toIntOrNull()
AmountInput(
rubles,
Penny(pennies, minOf(PENNIES_INPUT_LENGTH, penniesString.orEmpty().length)),
hasComma = splitties.size > 1
)
}
}
fun from(amount: Amount, rublesMaxLength: Int, hasComma: Boolean, enteredCount: Int): AmountInput {
val rubles = amount.rubles.toString().takeLast(rublesMaxLength).toInt()
return AmountInput(rubles, with(amount) { Penny(pennies, enteredCount) }, hasComma)
}
private fun String.removeLeadingExcessZeroes() =
foldIndexed("", { index, acc, c ->
if (acc.isEmpty() && c == ZERO && getOrNull(index + 1) == ZERO) acc else "$acc$c"
})
}
data class Penny(val value: Int?, val enteredCount: Int)
}iamthevoid
02/12/2022, 6:50 AMclass AmountTransformation(private val integersCount: Int) : VisualTransformation, OffsetMapping {
private lateinit var input: AmountInput
override fun filter(text: AnnotatedString): TransformedText {
input = AmountInput.from(text.toString(), integersCount)
return TransformedText(AnnotatedString(input.format(beautify = true)), this)
}
override fun originalToTransformed(offset: Int): Int {
return input.originalToTransformed(offset)
}
override fun transformedToOriginal(offset: Int): Int {
return input.transformedToOriginal(offset)
}
}iamthevoid
02/12/2022, 6:50 AM@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun EnterAmountTextField(
priceIntegersCount: Int,
amount: Amount,
onValueChange: (Amount) -> Unit
) {
val transformation by remember { mutableStateOf(AmountTransformation(integersCount = priceIntegersCount)) }
var enteredCount by remember { mutableStateOf(0) }
var hasComma by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.wrapContentWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
BasicTextField(
AmountInput.from(amount, priceIntegersCount, hasComma, enteredCount).format(),
onValueChange = { newValue ->
val input = AmountInput.from(newValue, priceIntegersCount)
hasComma = input.hasComma
enteredCount = input.pennies.enteredCount
onValueChange(input.amount)
},
singleLine = true,
modifier = Modifier.width(IntrinsicSize.Min),
textStyle = Typography.heading0.copy(fontWeight = FontWeight.Normal),
cursorBrush = SolidColor(Palette.Primary.Purple),
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
visualTransformation = transformation
)
Text(
stringResource(R.string.currency),
style = Typography.heading0.copy(fontWeight = FontWeight.Normal, color = Palette.Transparent.o25)
)
}
AnimatedVisibility(PaymentRuleTypes.isAmountInvalid(amount)) {
Text(amount.errorText(), style = Typography.descriptionRegular.copy(color = Palette.System.Error))
}
}
}iamthevoid
02/12/2022, 6:51 AMdata class Amount(val value: Int) {
constructor(
rubles: Int = 0,
pennies: Int = 0
) : this((rubles + pennies / kopeckInRuble) * kopeckInRuble + pennies % kopeckInRuble)
val rubles: Int
get() = value / kopeckInRuble
val pennies: Int
get() = value.absoluteValue % kopeckInRuble
fun formatPrice(showCurrencySign: Boolean = false): String =
"${formatPrice()}${if (showCurrencySign) " ₽" else "" }"
fun formatPrice(): String = buildString {
val integer = "$rubles".replace(NON_DIGIT_REGEX, DASH)
mutableListOf<CharSequence>().apply {
for (i in integer.length downTo 1 step DIGIT_GROUP_LENGTH) {
add(integer.subSequence(maxOf(i - DIGIT_GROUP_LENGTH, 0), i))
}
}.reversed().apply {
forEachIndexed { index, charSequence ->
append(charSequence)
if (index != lastIndex && charSequence != DASH) append(' ')
}
}
if (pennies > 0) append(",$pennies")
}
operator fun plus(amount: Amount): Amount = Amount(value + amount.value)
operator fun minus(amount: Amount): Amount = Amount(value - amount.value)
operator fun times(times: Int): Amount = Amount(value * times)
operator fun compareTo(amount: Amount): Int = value.compareTo(amount.value)
operator fun compareTo(amount: Int): Int = value.compareTo(amount)
fun positive(): Boolean = value > 0
fun negative(): Boolean = value < 0
companion object {
private const val kopeckInRuble = 100
private const val DIGIT_GROUP_LENGTH = 3
private const val DASH = "−"
private val NON_DIGIT_REGEX by lazy { Regex("\\D") }
val DEFAULT = Amount(-1)
val EMPTY = Amount(0)
}
}