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.
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.