por

Como formatar CNPJ em Jetpack Compose

O CNPJ (Cadastro Nacional de Pessoa Jurídica) está presente na vida de muitas empresas e frequentemente precisamos receber este dado em um aplicativo por meio de um campo de texto. Diferentemente do desenvolvimento de telas tradicional que permitia a entrada de dados por meio do EditText, em Compose precisamos utilizar o TextField (na verdade, existe também o BasicTextField, que provê mais opções de customização visual) e isso exige modificações na forma de lidar com máscaras de texto.

O CNPJ é um código numérico de 14 dígitos que segue o formato definido no Anexo XV da Instrução Normativa nº 2119 da Receita Federal:

Para permitir a implementação de máscara de textos, o composable TextField pode receber como argumento um a implementação da interface VisualTransformation, que possui somente um método, filter, que recebe o texto original e retorna um TransformedText. Segue um excerto da definição da interface:

@Immutable
fun interface VisualTransformation {
/*
  Params:
  text - The original text
  Returns:
  the pair of filtered text and offset translator.
*/
    fun filter(text: AnnotatedString): TransformedText

    companion object {
        /**
         * A special visual transformation object indicating that no transformation is applied.
         */
        @Stable
        val None: VisualTransformation = VisualTransformation { text ->
            TransformedText(text, OffsetMapping.Identity)
        }
    }
}

O retorno deste método deve conter tanto uma string anotada quanto um OffsetMapping. O OffsetMapping é importante para fazer com que o cursor saiba se mover nos índices em que são adicionados os caracteres extras da máscara.

Inicialmente, iremos criar um campo de texto e passaremos a implementação CnpjVisualTransformation da interface VisualTransformation para ele. Neste ponto, esta interface não faz nada, apenas retorna uma string exatamente igual à que foi inserida pelo usuário. Para fazer isso, utilizamos um OffsetMapping.Identity. Este OffsetMapping não realiza nenhum mapeamento.

@Composable
fun ExampleTextField(modifier: Modifier = Modifier) {
    var input by remember { mutableStateOf("") }
    TextField(value = input, onValueChange = {
        input = it
    }, visualTransformation = CnpjVisualTransformation())
}

class CnpjVisualTransformation: VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val transformedCnpj = buildString {
            for(i in text.text.indices) {
                append(text[i])
            }
        }

        return TransformedText(AnnotatedString(transformedCnpj), OffsetMapping.Identity)
    }
}

Definição do filtro

Agora, iremos começar a fazer a modificação na string original, recebida pelo método filter. Para isso, verificamos os índices da string e adicionamos os caracteres conforme o padrão do CNPJ.

    override fun filter(text: AnnotatedString): TransformedText {
        val transformedCnpj = buildString {
            for(i in text.text.indices) {
                append(text[i])
                if(i == 1 || i == 4) append(".")
                if(i == 7) append("/")
                if(i == 11) append("-")
            }
        }

        return TransformedText(AnnotatedString(transformedCnpj), OffsetMapping.Identity)
    }

Com o uso do método buildString da linguagem Kotlin, torna-se fácil adicionar os marcadores, basta adicionar o caractere original, e, a depender do índice, adicionar também o marcador pretendido. Para melhor entendimento a respeito dos índices utilizados, podemos utilizar a tabela de indices de um CNPJ de exemplo (CNPJ do Banco do Brasil)>

012345678910111213
00.000.000/000191

Agora que alteremos a string que será apresentada ao usuário, podemos verificar que somente utilizar o OffsetMapping.Identity não funcionará e obteremos o seguinte crash em tempo de execução do aplicativo:

java.lang.IllegalStateException: OffsetMapping.transformedToOriginal returned invalid mapping: 3 -> 3 is not in range of original text [0, 2]

Definição do OffsetMapping

A interface OffsetMapping requer a implementação de dois métodos: originalToTransformed e transformedToOriginal. Estes métodos servem para guiar o posicionamento do cursor ao longo do texto que está sendo escrito pelo usuário, uma vez que estamos alterando os índices originais ao inserir os marcadores do CNPJ. Segue a definição da interface na base de código do Android:

/**
 * Provides bidirectional offset mapping between original and transformed text.
 */
interface OffsetMapping {
    /**
     * Convert offset in original text into the offset in transformed text.
     *
     * This function must be a monotonically non-decreasing function. In other words, if a cursor
     * advances in the original text, the cursor in the transformed text must advance or stay there.
     *
     * @param offset offset in original text.
     * @return offset in transformed text
     *
     * @see VisualTransformation
     */
    fun originalToTransformed(offset: Int): Int

    /**
     * Convert offset in transformed text into the offset in original text.
     *
     * This function must be a monotonically non-decreasing function. In other words, if a cursor
     * advances in the transformed text, the cusrsor in the original text must advance or stay
     * there.
     *
     * @param offset offset in transformed text
     * @return offset in original text
     *
     * @see VisualTransformation
     */
    fun transformedToOriginal(offset: Int): Int
}

Para encontrar definições de interfaces e classes do Android, recomendo o uso do Android Code Search: https://cs.android.com/

Tendo implementado a adição dos marcadores na string original, resta somente implementar os métodos que definem o posicionamento do cursor no campo de texto.

Implementando a função originalToTransformed

O método originalToTransformed mapeia o posicionamento do cursor na string original, para a sua posição após a inclusão dos marcadores na string pretendida. Para melhor compreender, podemos pensar que estamos, a depender do índice, adicionando um offset (deslocamento) no cursor do campo de texto.

        override fun originalToTransformed(offset: Int): Int {
            if (offset >= 12) return offset + 4
            if (offset >= 8) return offset + 3
            if (offset >= 5) return offset + 2
            if (offset >= 2) return offset + 1
            else return offset
        }

Utilizando a mesma tabela que utilizamos acima, verificamos que, a partir do índice 12 da string original, devemos “pular” 4 casas adicionais, pois já existem 4 marcadores a partir desta posição. A partir da posição 8, devemos pular 3 casas e assim por diante até chegarmos ao caso base de não termos de pular nenhuma casa, e neste caso apenas retornamos o offset original, que será zero (sem deslocamento).

012345678910111213
00.000.000/000191

Implementando a função transformedToOriginal

Diferentemente dos métodos anteriores, em que trabalhamos somente com os índices da string original, sem contar com os índices dos marcadores, o método transformedToOriginal requer que contemos com todos os índices (repare que as células da tabela que estavam em branco agora estão preenchidas).

        override fun transformedToOriginal(offset: Int): Int {
            if (offset >= 16) return offset - 4
            if (offset >= 11) return offset - 3
            if (offset >= 7) return offset - 2
            if (offset >= 3) return offset - 1
            else return offset
        }

Se antes somávamos o deslocamento para contar com os marcadores, neste método nós subtraímos o deslocamento para não contar com eles. Observe que, a partir do índice 16 da string com os marcadores, já contamos com 4 marcadores, e portanto precisamos subtraí-los. De forma semelhante, a partir do índice 11, contamos com 3 marcadores previamente adicionados, e agora precisamos subtraí-los para obter o índice da string original e assim por diante.

01234567891011121314151617
00.000.000/000191

Código completo

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

    TextField(
        modifier = modifier,
        value = input,
        onValueChange = {
            if (it.length <= 14) input = it
        },
        visualTransformation = CnpjVisualTransformation(),
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
    )
}

class CnpjVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val transformedCnpj = buildString {
            for (i in text.text.indices) {
                append(text[i])
                if (i == 1 || i == 4) append(".")
                if (i == 7) append("/")
                if (i == 11) append("-")
            }
        }

        return TransformedText(AnnotatedString(transformedCnpj), cnpjMapping)
    }

    val cnpjMapping = object : OffsetMapping {
        override fun originalToTransformed(offset: Int): Int {
            if (offset >= 12) return offset + 4
            if (offset >= 8) return offset + 3
            if (offset >= 5) return offset + 2
            if (offset >= 2) return offset + 1
            else return offset
        }

        override fun transformedToOriginal(offset: Int): Int {
            if (offset >= 16) return offset - 4
            if (offset >= 11) return offset - 3
            if (offset >= 7) return offset - 2
            if (offset >= 3) return offset - 1
            else return offset
        }
    }

Máscara em funcionamento

Próximos passos

  • Consegue ver como criar uma transformação visual genérica, que receba como parâmetro a máscara a ser aplicada no formato “###.###.###”, por exemplo, para poder aplicá-la em um campo de texto qualquer?