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?val 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)
}
class 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)
}
}
@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))
}
}
}
data 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)
}
}