Algumas vezes, erros são repetidos como padrões por diversas pessoas e replicados em diversos cenários diferentes. O desenvolvimento para Android nativo não é muito diferente.

Recentemente, notei um desses erros que são cometidos com frequência, mas tratados como algo normal e tendo seus efeitos colaterais tratados com soluções um tanto improvisadas.

TextWatcher, implementação comum e o problema

No geral, as implementações de TextWatcher são utilizadas para fazer validações no input ou aplicar máscaras de texto. Este segundo sendo o alvo desse artigo. Uma implementação comum que observei em diversos projetos tinha uma sequência semelhante no processo de aplicação das máscaras:

  • Remover o TextWatcher na chamada do onTextChanged oafterTextChange
  • Aplicar máscara e atribuir o valor ao EditText novamente com setText
  • Ajustar a posição do cursor do input
  • Atribuir novamente o listener

Abaixo exemplifico um código gerado por IA, reforçando que diversos códigos utilizados como fonte implementam da forma incorreta:

class CpfTextWatcher(
  private val editText: EditText
) : TextWatcher {

  private val mask = CpfMask()

  override fun beforeTextChanged(
    s: CharSequence?,
    start: Int,
    count: Int,
    after: Int,
  ) = Unit

  override fun onTextChanged(
    s: CharSequence?,
    start: Int,
    before: Int,
    count: Int,
  ) = Unit

  override fun afterTextChanged(s: Editable?) {
    editText.removeTextChangedListener(this)

    val current = s.toString()
    val unmasked = current.replace(Regex("[^\\d]"), "")
    val formatted = mask.applyMask(unmasked)

    editText.setText(formatted)
    editText.setSelection(formatted.length)

    editText.addTextChangedListener(this)
  }
}

Qual o problema exatamente?

Sem mistérios, o maior problema dessa implementação é o EditText.setText. Quando utilizamos o EditText.setText, o estado do input e do teclado é completamente reiniciado. O efeito desse reinício é o cursor voltando para o início, e o teclado caso esteja na aba de caracteres especiais seja forçado a voltar para o teclado padrão com letras.

É por conta do EditText.setText que sempre é necessário reposicionar o cursor manualmente, e isso é um problema!

Qual a forma correta de aplicar a máscara?

O correto é utilizar o objeto Editable do método afterTextChanged. É um objeto criado justamente para aplicar as mudanças necessárias e ideal para aplicar o texto mascarado. O código corrigido seria algo dessa forma:

class CpfTextWatcher : TextWatcher {
  ...
  override fun afterTextChanged(s: Editable?) {
    s?.let { editable ->
      val current = editable.toString()
      val unmasked = current.replace(Regex("[^\\d]"), "")
      val formatted = mask.applyMask(unmasked)

      editable.replace(0, current.length, formatted)
    }
  }
}

Dessa forma, por não utilizar o EditText.setText os estados não são reiniciados, e não temos o problema de reposicionamento do cursor do input, e a experiência do usuário fica muito mais fluida e agradável.

Por que o EditText.setText reinicia o teclado?

Este ponto me inquietou enquanto eu não tinha a resposta. Então verifiquei os códigos das implementações básicas da View no código fonte do Android, no Android Code Search.

O EditText herda de TextView, e utiliza o TextView.setText. Analisando o código e navegando algumas funções adentro, encontramos esse método que faz a atribuição do novo texto:

None
Android Code Search — clique aqui para acessar o trecho

Descendo um pouco nesse mesmo método, encontramos a linha 7378 que utiliza o Editor. O Editor é uma classe que faz o gerenciamento visual do que é mostrado, como seleção de texto, cursor, autocomplete e outras coisas.

None
Android Code Search — clique aqui para acessar o trecho

O método Editor.scheduleRestartInputForSetText basicamente faz a atribuição como true da variável mHasPendingRestartInputForSetText. Essa variável é utilizada mais tarde no método Editor.maybeFireScheduledRestartInputForSetText, que executa o invalidate no InputMethodManager, o gerenciador do teclado virtual, o que ocasiona no reinício do teclado.

None
Android Code Search — clique aqui para acessar o trecho

Então, o método Editor.maybeFireScheduledRestartInputForSetText é chamado logo abaixo no mesmo método TextView.setText, ocasionando o reinício do estado do teclado.

None
Android Code Search — clique aqui para acessar o trecho

Finalização

Espero que tenha sido interessante entender melhor como ajustar a implementação do TextWatcher e a razão do problema causado pela implementação incorreta. Estou aberto a correções/sugestões pelos comentários ou por redes sociais!