Pular para o conteúdo
Sunstone Apps
← Voltar para o blog
React Native

Um TextInput escondido não captura teclado físico na New Architecture do React Native

O teclado devia continuar escondido. Não continuou. O dumpsys disse que o teclado virtual estava na tela mesmo depois de pedirmos que não estivesse, e essa única linha de adb mudou toda a abordagem.

Publicado: 8 min de leitura
Imagem de prévia OpenGraph deste artigo. Um TextInput escondido não captura teclado físico na New Architecture do React Native

O Daily Sudoku: Offline Puzzle já suportava teclado físico no build web. Você seleciona uma célula, aperta 1 a 9, usa Backspace para apagar e h, a, n para dica, auto notas e alternar notas. Na web são poucas linhas: um handler document.addEventListener("keydown", ...) que mapeia teclas para as mesmas ações dos botões na tela.

Aí quisemos o mesmo no Android, porque as pessoas testam em emulador com o teclado do host e algumas jogam no celular com um teclado USB ligado. Na web aquele listener de document dá conta. No nativo não existe document, então nada estava ligado. A versão Android simplesmente nunca tinha suportado esse recurso.

Este texto é sobre o caminho errado que pegamos primeiro, o comando de adb que derrubou a hipótese, e o conserto com config plugin que se sustenta na New Architecture do React Native.

A primeira tentativa óbvia

Se você procura “capturar teclado físico React Native”, cai quase na hora no truque do TextInput escondido. A ideia é simples, e é realmente como muitos apps de leitor de código de barras funcionam: renderizar um TextInput invisível e sempre focado, dizer para ele não mostrar o teclado virtual, e ler os caracteres conforme chegam.

A versão que escrevemos parecia razoável:

<TextInput
  ref={inputRef}
  value=""
  onChangeText={handleChangeText}
  onKeyPress={handleKeyPress}
  onBlur={() => inputRef.current?.focus()}
  autoFocus
  caretHidden
  showSoftInputOnFocus={false}
  style={{ position: "absolute", left: -1000, top: -1000, opacity: 0 }}
/>

O onChangeText entrega o caractere digitado (números e letras), o onKeyPress entrega o Backspace, e showSoftInputOnFocus={false} é a forma documentada de impedir o teclado da tela de subir enquanto o campo está focado. Num celular com teclado físico você quer o campo focado para receber as teclas, mas nunca quer o teclado de toque cobrindo metade do tabuleiro.

Passou no typecheck, passou no lint, parecia pronto. Não estava.

A verificação desconfortável: pergunte ao aparelho, não ao código

A forma honesta de confirmar “o teclado virtual não está aparecendo” não é olhar de relance para o emulador. É perguntar diretamente ao Android. O serviço de input method diz o que ele acha que está fazendo:

adb -s emulator-5554 shell dumpsys input_method | grep -iE "mInputShown|mServedView"

A resposta foi seca:

mInputShown=true
mServedView=com.facebook.react.views.textinput.ReactEditText{... -2625,-2625 ...}

Dois fatos numa linha. Primeiro, mInputShown=true: o teclado virtual estava na tela apesar do showSoftInputOnFocus={false}. Segundo, a view com o foco de entrada era um ReactEditText posicionado em -2625, -2625. Esse era o nosso campo escondido. O left: -1000 em pixels independentes de densidade, num emulador de 420 dpi com fator de escala 2,625, cai exatamente em -2625 pixels reais. A view servida era a nossa, estava focada, e o teclado que ela invocou estava aparecendo.

A prop na qual confiávamos estava sendo ignorada. Não “às vezes instável”. Ignorada, no build que estava diante de nós.

Por que a prop é ignorada: a New Architecture

O projeto roda na New Architecture do React Native, com newArchEnabled=true no gradle.properties. O showSoftInputOnFocus={false} mapeia para o setShowSoftInputOnFocus(false) do EditText no Android, e esse caminho tem um histórico longo e bem documentado de não ser respeitado no Android, especialmente ao mudar de ciclo de vida e de foco. No Fabric é pior: as respostas comuns viram uma gambiarra em que você chama Keyboard.dismiss() dentro do onFocus e refoca o campo, o que faz o teclado piscar e às vezes perde o foco de vez. Isso não é conserto, é uma briga com a plataforma que o jogador assiste acontecer.

Tem um ponto mais profundo aqui, fácil de passar batido. A abordagem do TextInput escondido tenta fazer um campo de texto encaminhar teclas enquanto suprime o próprio teclado. Numa stack em que a prop de supressão é não confiável, o truque inteiro está montado justamente sobre a parte que não funciona. Nenhum ajuste de prop muda o fato de que a estratégia depende de uma garantia quebrada.

A outra armadilha silenciosa: o argumento a favor do input escondido era “não precisa de rebuild nativo, é só JS”. Mas não havia Metro rodando; o app em teste era um APK estilo release. Qualquer mudança, JS ou nativa, exigia rebuild para testar. Então a única vantagem real da abordagem já tinha evaporado, e o que sobrou foi o defeito.

O conserto: capturar as teclas na Activity, não num campo de texto

Um teclado físico no Android entrega os eventos de tecla para a Activity focada através do onKeyDown. Se você os trata ali, nunca precisa de um campo de texto focado, o que significa que você nunca invoca um IME, o que significa que o teclado virtual simplesmente não pode aparecer. É essa a ideia inteira por trás do react-native-keyevent: ele expõe o onKeyDown/onKeyUp da Activity para o JavaScript via event emitter.

O lado JavaScript é pequeno de propósito:

import KeyEvent from "react-native-keyevent";

React.useEffect(() => {
  if (Platform.OS !== "android") return;

  KeyEvent.onKeyDownListener((event) => {
    if (event.keyCode === 67 || event.keyCode === 112) {
      onErase(); // Backspace / Forward Delete
      return;
    }
    const key = (event.pressedKey ?? "").toLowerCase();
    if (key >= "1" && key <= "9") onDigit(Number(key));
    else if (key === "0") onErase();
    else if (key === "h") onHint();
    else if (key === "a") onAutoNotes();
    else if (key === "n") onToggleNotes();
  });

  return () => KeyEvent.removeKeyDownListener();
}, []);

return null;

Repare que o componente renderiza null. Não há view, não há campo de texto, nada focável. Usamos keyCode para as teclas de apagar (o backspace nem sempre chega como caractere imprimível) e pressedKey para o resto. A web continua no listener de document; o iOS fica de propósito sem efeito, porque não instalamos o gancho no AppDelegate lá.

A metade nativa precisa ser um config plugin

O react-native-keyevent precisa que a Activity encaminhe os eventos de tecla:

override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
  if (event != null) {
    KeyEventModule.getInstance().onKeyDownEvent(keyCode, event)
  }
  return super.onKeyDown(keyCode, event)
}

O instinto é colar isso no MainActivity.kt. Não cole. Num projeto Expo a pasta android/ inteira é gerada pelo expo prebuild, então uma edição manual no MainActivity.kt é apagada na próxima vez que alguém regenerar o projeto. O lugar durável para mudanças nativas é um config plugin.

Nosso plugin usa withMainActivity, insere os dois imports logo depois da linha do package e acrescenta os overrides antes da chave de fechamento da classe. Ele também valida a linguagem, porque um MainActivity em Kotlin e um em Java precisam de código diferente:

const { createRunOncePlugin, withMainActivity } = require("expo/config-plugins");

const withKeyEvent = (config) =>
  withMainActivity(config, (mod) => {
    if (mod.modResults.language !== "kt") {
      throw new Error("[withKeyEvent] Expected a Kotlin MainActivity.");
    }
    let contents = mod.modResults.contents;
    // ...insere imports após a linha do package, acrescenta overrides antes da última chave...
    mod.modResults.contents = contents;
    return mod;
  });

module.exports = createRunOncePlugin(withKeyEvent, "with-key-event", "1.0.0");

Registre no app.json ao lado dos outros plugins e ele roda em todo prebuild. Como a mudança agora faz parte do prebuild, o comando de build importa: um build Gradle incremental rápido pula o prebuild e não aplica o plugin nem vincula o módulo novo. Você precisa do caminho completo que roda expo prebuild primeiro. No nosso repositório isso é o pnpm sudoku:apk:prd:full; a variante rápida pnpm sudoku:apk:prd de propósito não roda prebuild.

Ressalvas honestas

O react-native-keyevent é um módulo legado, construído sobre o bridge antigo. Na New Architecture ele roda pela camada de interop, o que é suficiente para um event emitter simples como este. Ainda assim, é melhor dizer isso claramente do que fingir que ele virou um módulo Fabric de primeira classe. Se o listener um dia ficar mudo depois de um upgrade de RN, a camada de interop é o primeiro suspeito.

Também limitamos o escopo. Dígitos, apagar e três atalhos de letra cobrem praticamente todo o valor de jogar Sudoku com teclado. A navegação por setas fica só na web, não porque seja impossível com o react-native-keyevent, mas porque não valia a superfície extra para como o jogo é de fato jogado. Entregar o caminho útil ganha de polir um atalho que quase ninguém pediu.

O aprendizado

Dois hábitos fizeram o trabalho de verdade aqui. O primeiro: quando uma afirmação de UI é testável, teste contra o aparelho em vez do olho. O mInputShown=true encerrou uma discussão que “para mim parece escondido” poderia ter arrastado por uma hora. O segundo: case a ferramenta com as garantias reais da plataforma. Um TextInput escondido se apoia no showSoftInputOnFocus, e uma vez que você sabe que essa prop é não confiável no Fabric, você para de tentar fazer uma fundação quebrada sustentar o recurso e move a captura para onde a plataforma é confiável, a própria Activity.

O teclado virtual fica abaixado agora, as teclas de número caem nas células certas, e conseguimos provar isso com uma linha de adb em vez de apertar os olhos na tela.

Falar com a Sunstone Apps