用JetPack组成的Visual Transtryforation用Jetpack的十进制输入格式
#android #组成 #decimal

处理(长)小数号时,我们通常希望通过添加适当的分离器来格式化它们,从而使用户很容易阅读。当前,没有内置功能来支持此功能。但是,撰写为我们提供了VisualTransformation功能,用于更改文本字段中的视觉输出。在这篇简短的文章中,我们将看到如何使用此功能来实现适当的小数格式。

Ban Markovic有一个很棒的Medium article,这是本文的灵感,涵盖了类似的情况。他的解决方案专注于货币格式,因此对输入格式的严格不严格。如果您要处理格式的货币输入,请参阅BAN的文章。

十进制键盘类型

让我们从带有键盘类型的简单输入文本字段设置为十进制输入。

@Composable
fun DecimalInputField(modifier: Modifier = Modifier) {
    var text by remember {
        mutableStateOf("")
    }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = { text = it },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal
        )
    )
}

您可以看到这里没有什么花哨的。我们需要注意的一件关键是KeyboardType.Decimal不能保证正确的小数输入。具体而言,用户可以输入多个小数分离器,随机分离器或其他一些不需要的输入,例如下面的图像中。

Incorrect decimal input screenshot

因此,在格式化小数之前,我们需要确保其以正确的形式。在设置输入字段的新值之前,我们将执行此清理过程。为了使事情保持整洁,我将举办一个名为DecimalFormatter的课程,并在该类中写相关的功能。

class DecimalFormatter(
    symbols: DecimalFormatSymbols = DecimalFormatSymbols.getInstance()
) {

    private val thousandsSeparator = symbols.groupingSeparator
    private val decimalSeparator = symbols.decimalSeparator

    fun cleanup(input: String): String {

        if (input.matches("\\D".toRegex())) return ""
        if (input.matches("0+".toRegex())) return "0"

        val sb = StringBuilder()

        var hasDecimalSep = false

        for (char in input) {
            if (char.isDigit()) {
                sb.append(char)
                continue
            }
            if (char == decimalSeparator && !hasDecimalSep && sb.isNotEmpty()) {
                sb.append(char)
                hasDecimalSep = true
            }
        }

        return sb.toString()
    }
}

代码非常不言自明。让我只解释一下我们在这里遵循的规则。

  1. 如果输入某物非数字,则只需返回空字符串。
  2. 如果输入由连续的零组成,则返回单个零。
  3. 仅允许使用一个小数分离器的数字输入(在这种情况下为第一个)。

现在让我们将此清理过程添加到我们的小数输入字段中。

@Composable
fun DecimalInputField(
    modifier: Modifier = Modifier,
    decimalFormatter: DecimalFormatter
) {

    var text by remember {
        mutableStateOf("")
    }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = {
            text = decimalFormatter.cleanup(it)
        },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal
        )
    )
}

视觉变形

如前所述,VisualTransformation是一个功能,它使我们能够更改输入字段的视觉输出。它只是一个称为filter()的单个方法的接口。我们的原始输入传递给了此方法,并返回了一种方法。重要的是要指出,VisualTransformation不会更改字段的输入值。它只是改变了视觉输出。换句话说,在本文的上下文中,我们的原始输入值(存储在text变量中)将没有数千个分离器。让我们深入探讨代码。

首先,让我们查看实际上将数千个分离器插入十进制数字的代码。如下所示,formatForVisual方法处理此任务,我们将此方法称为VisualTransfromation类。代码很简单。由于输入字符串在传递给formatForVisual方法之前经过清理过程,因此我们可以假设它以正确的形式。我们只是根据小数分隔符将字符串分开,然后在整数部分添加数千个分离器,然后将新的整数部分和十进制部分加成。

class DecimalFormatter(
    symbols: DecimalFormatSymbols = DecimalFormatSymbols.getInstance()
) {

    private val thousandsSeparator = symbols.groupingSeparator
    private val decimalSeparator = symbols.decimalSeparator

    fun cleanup(input: String): String {
        // Refer above snippet for the implementation.
    }

    fun formatForVisual(input: String): String {

        val split = input.split(decimalSeparator)

        val intPart = split[0]
            .reversed()
            .chunked(3)
            .joinToString(separator = thousandsSeparator.toString())
            .reversed()

        val fractionPart = split.getOrNull(1)

        return if (fractionPart == null) intPart else intPart + decimalSeparator + fractionPart
    }
}

终于可以研究VisualTransformation。如您所见,我们将界面实现为DecimalInputVisualTransformation。而且,由于我们处理DecimalFormatter代码中的格式逻辑,因此filter()方法非常简洁。首先,我们格式化输入文本,并制作包含格式化数字的新注释字符串。我们确保新的注释字符串遵循原始输入文本的样式。

我们需要考虑的另一个重要的事情是光标的位置。我认为以下示例将有助于我们更好地了解情况。想象一下,用户将光标移至数千分离器后的位置,然后单击删除。由于原始输入没有一千个分离器,因此该位置映射到数字2和3之间的位置。因此,当用户单击删除时,将删除数字2,最好在原始输入中以及在视觉输出中成千上万的分隔符之后的数字之后保留。<<<<<<<< br>

// Original input before deletion
input = 12<cursor>345.67

// Visual output before deletion
output = 12,<cursor>345.67

---

// Original input after deletion
input = 1<cursor>345.67

// Visual output after deletion
output = 1,<cursor>345.67

我们使用OffsetMapping接口处理。根据documentation OffsetMapping的统计,在原始文本和转换文本之间提供了双向偏移映射。在这里,为了使事情变得简单,我们总是希望光标在文本的末尾保持。如您在FixedCursorOffsetMapping类中所看到的,我们只返回文本的长度,使我们可以将光标固定到末端。

最后,我们制作了一个TransformedText对象的格式化文本和偏移映射实例并返回。

class DecimalInputVisualTransformation(
    private val decimalFormatter: DecimalFormatter
) : VisualTransformation {

    override fun filter(text: AnnotatedString): TransformedText {

        val inputText = text.text
        val formattedNumber = decimalFormatter.formatForVisual(inputText)

        val newText = AnnotatedString(
            text = formattedNumber,
            spanStyles = text.spanStyles,
            paragraphStyles = text.paragraphStyles
        )

        val offsetMapping = FixedCursorOffsetMapping(
            contentLength = inputText.length,
            formattedContentLength = formattedNumber.length
        )

        return TransformedText(newText, offsetMapping)
    }
}

private class FixedCursorOffsetMapping(
    private val contentLength: Int,
    private val formattedContentLength: Int,
) : OffsetMapping {
    override fun originalToTransformed(offset: Int): Int = formattedContentLength
    override fun transformedToOriginal(offset: Int): Int = contentLength
}

让我把它放在一起

@Composable
fun DecimalInputField(
    modifier: Modifier = Modifier,
    decimalFormatter: DecimalFormatter
) {

    var text by remember {
        mutableStateOf("")
    }

    OutlinedTextField(
        modifier = modifier,
        value = text,
        onValueChange = {
            text = decimalFormatter.cleanup(it)
        },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal,
        ),
        visualTransformation = DecimalInputVisualTransformation(decimalFormatter)
    )
}

此外,这里有一些DecimalFormatter类的单元测试。

class DecimalFormatterTest {

    private val subject = DecimalFormatter(symbols = DecimalFormatSymbols(Locale.US))

    @Test
    fun `test cleanup decimal without fraction`() {
        val inputs = arrayOf("1", "123", "123131231", "3423423")
        for (input in inputs) {
            val result = subject.cleanup(input)
            assertEquals(input, result)
        }
    }

    @Test
    fun `test cleanup decimal with fraction normal case`() {
        val inputs = arrayOf(
            "1.00", "123.1", "1231.31231", "3.423423"
        )

        for (input in inputs) {
            val result = subject.cleanup(input)
            assertEquals(input, result)
        }
    }

    @Test
    fun `test cleanup decimal with fraction irregular inputs`() {
        val inputs = arrayOf(
            Pair("1231.12312.12312.", "1231.1231212312"),
            Pair("1.12312..", "1.12312"),
            Pair("...12..31.12312.123..12.", "12.311231212312"),
            Pair("---1231.-.-123-12.1-2312.", "1231.1231212312"),
            Pair("-.--1231.-.-123-12.1-2312.", "1231.1231212312"),
            Pair("....", ""),
            Pair(".-.-..-", ""),
            Pair("---", ""),
            Pair(".", ""),
            Pair("      ", ""),
            Pair("     1231.  -   12312.   -   12312.", "1231.1231212312"),
            Pair("1231.  -   12312.   -   12312.     ", "1231.1231212312")
        )

        for ((input, expected) in inputs) {
            val result = subject.cleanup(input)
            assertEquals(expected, result)
        }
    }

    @Test
    fun `test formatForVisual decimal without fraction`() {
        val inputs = arrayOf(
            Pair("1", "1"),
            Pair("12", "12"),
            Pair("123", "123"),
            Pair("1234", "1,234"),
            Pair("12345684748049", "12,345,684,748,049"),
            Pair("10000", "10,000")
        )

        for ((input, expected) in inputs) {
            val result = subject.formatForVisual(input)
            assertEquals(expected, result)
        }
    }

    @Test
    fun `test formatForVisual decimal with fraction`() {
        val inputs = arrayOf(
            Pair("1.0", "1.0"),
            Pair("12.01723817", "12.01723817"),
            Pair("123.999", "123.999"),
            Pair("1234.92834928", "1,234.92834928"),
            Pair("12345684748049.0", "12,345,684,748,049.0"),
            Pair("10000.0009", "10,000.0009"),
            Pair("0.0009", "0.0009"),
            Pair("0.0", "0.0"),
            Pair("0.0100008", "0.0100008"),
        )

        for ((input, expected) in inputs) {
            val result = subject.formatForVisual(input)
            assertEquals(expected, result)
        }
    }
}

感谢您的关注。希望您发现这篇文章有帮助。您可以到达github repo here

封面图像由Mika Baumeister

提供