React Native Skia
A borda arredondada que jogou todos os dígitos do Sudoku para o lado
Quem testava olhou o tabuleiro e disse que os números pareciam tortos. Concordamos, culpamos a fonte e erramos. O conserto veio de medir pixels num screenshot, não de confiar no olho.
Demos ao tabuleiro do Daily Sudoku: Offline Puzzle uma moldura arredondada. Borda de verdade, cor de tinta quente, cantos suaves. Ficou melhor no code review e melhor no aparelho. Aí a pessoa que estava testando disse que os dígitos pareciam levemente tortos, meio empurrados para um lado, e sinceramente um pouco feios.
Essa frase começou uma sessão de depuração que ensinou a mesma lição duas vezes: quando uma tela parece levemente errada, o olho é o instrumento menos confiável da sala.
Primeiro erro: culpar o glifo
O suspeito óbvio era o algarismo 1. Ele tem uma bandeira em cima e uma haste fina, então parece inclinado para a direita. Minha primeira explicação foi confiante e confortável: o 1 é assim mesmo, toda fonte faz isso, não há o que consertar.
Eu poderia ter entregado essa explicação. Em vez disso, puxei um screenshot do Redmi Note 7 via adb e medi. Não “olhei com atenção”. Medi. Um script pequeno achou a moldura do tabuleiro, dividiu em uma grade 9×9 e calculou, para cada dígito impresso, o centro horizontal dos pixels escuros em relação à célula.
O 1 não tinha nada de especial. Todo dígito ficava em torno de 55% da largura da célula, e não 50%. Cincos, oitos, setes, todos, uniformemente ~5% à direita. Um único glifo não causa um desvio uniforme em todos os glifos. Então minha primeira explicação não era só incompleta: apontava para a coisa errada por inteiro.
Essa é a parte que vale internalizar. “O 1 inclina” parecia verdade porque eu já acreditava nisso antes de olhar. A medição não ligou para o que eu acreditava.
Segundo erro: consertar a conta da centralização
O tabuleiro é desenhado num <Canvas> do Skia. Cada dígito sai com SkiaText, e o código centralizava usando as bounds do texto:
const bounds = font.measureText(text);
x = cellLeft + (cellSize - bounds.width) / 2;
Existe uma pegadinha conhecida aqui: measureText devolve um retângulo com um x, o left side bearing do glifo, e o código acima ignorava isso. Então fiz o conserto de manual e centralizei pela caixa de tinta de verdade:
x = cellLeft + cellSize / 2 - (bounds.x + bounds.width / 2);
Recompilei, reinstalei, medi de novo. Foi de ~55% para ~55%. Praticamente nada.
Tudo bem. Talvez as bounds do measureText sejam pouco confiáveis para a fonte de sistema. Troquei para centralizar pelo advance do glifo via getGlyphWidths, um caminho de código completamente diferente. Recompilei, medi: ~54,6%. Ainda torto, ainda pelo mesmo tanto, qualquer que fosse o método.
Duas estratégias de centralização independentes produzindo o mesmo erro não é bug de centralização. É sinal de que aquilo dentro do que eu centralizava não estava onde eu pensava. O dígito estava certo na célula. A célula é que estava no lugar errado.
A causa real: uma borda que redimensionou o content-box
Esta era a moldura que eu tinha adicionado:
<View className="overflow-hidden border-2 ..." style={{ width: boardSize, height: boardSize }}>
<SudokuGrid boardSize={boardSize} />
</View>
Leia devagar. O container tem boardSize de largura e uma borda de 2dp. No React Native, a borda fica dentro da caixa, então a área de conteúdo é boardSize - 4, não boardSize. Mas o canvas Skia lá dentro continuava recebendo boardSize inteiro. Com overflow-hidden, o canvas foi posicionado a partir da origem do content-box, que fica a uma espessura de borda da beira externa, e a largura total dele transbordava o content-box à direita, onde era cortada.
O efeito líquido: o grid inteiro deslocado para dentro pela espessura da borda, uns 2dp, e a última coluna e a última linha cortadas silenciosamente pelo mesmo tanto. Cada célula andou para a direita e para baixo pela borda. Os dígitos estavam no centro exato das suas células reais; minha medição no screenshot comparava com a moldura externa, então o deslocamento do grid inteiro aparecia como um desvio uniforme por dígito.
A borda não desenhou só um quadro. Ela mudou o tamanho do espaço que o tabuleiro deveria preencher, e ninguém avisou o tabuleiro.
O conserto é pequeno quando você enxerga. Dimensione o canvas para o content-box e mantenha a espessura da borda numa única constante, para os dois nunca desalinharem:
const BOARD_FRAME_BORDER = 2;
<View
className="overflow-hidden"
style={{ width: boardSize, height: boardSize, borderWidth: BOARD_FRAME_BORDER }}
>
<SudokuGrid boardSize={boardSize - BOARD_FRAME_BORDER * 2} />
</View>;
Agora o canvas é exatamente o content-box. Sem overflow, sem corte, sem deslocamento. Com o offset resolvido, a centralização comum por bounds finalmente faz o que sempre prometeu. A medição no screenshot foi para 50,1% na horizontal e 48,9% na vertical. Centro exato, incluindo as células de canto que antes eram cortadas.
Por que isso passa batido
Um filho de tamanho fixo dentro de uma caixa com borda e overflow-hidden falha em silêncio. Nada lança erro. O layout não avisa. Os cantos continuam arredondados porque o overflow está escondido exatamente como se queria. A faixa cortada na beira é só um par de pixels independentes de densidade, invisível a menos que você vá procurar. E o desvio é uniforme, então nunca lê como “esta célula está quebrada”, que é o tipo de coisa que o olho humano de fato pega. Lê como um vago “os números parecem um pouco tortos”, que é o tipo de coisa que o olho racionaliza e ignora.
O Skia facilita cair nisso porque um <Canvas> não se ajusta ao content-box do pai como uma View comum com flex: 1 faria. Você entrega um tamanho explícito em pixels, e se esse número veio do border-box, a borda o consome em silêncio. O renderer nativo React Native do mesmo tabuleiro não tinha o bug, porque as células dele são filhos flex que se redistribuem dentro de qualquer content-box que recebam. Mesmo layout, dois renderers, só o de tamanho fixo estava errado.
A outra coisa que já tínhamos errado naquele dia
A borda não foi a primeira vez que o olho nos enganou nessa mesma tela. O redesign tinha subido os dígitos fixos para o peso de fonte 600, para eles ficarem mais firmes que os palpites do jogador. No aparelho de teste pareciam pesados, quase bold, e quem testava sinalizou.
O motivo é um detalhe de plataforma fácil de esquecer: o sans-serif de sistema do Android é o Roboto, e o Roboto tem pesos 400, 500 e 700. Não existe 600. Peça 600 e a plataforma arredonda para a face real mais próxima, o que tende a cair no Bold. Então um valor que parece um degrau suave numa ferramenta de design vira um salto duro no dispositivo. Voltamos para 400 e deixamos só a cor separar dica de palpite, que é o que o design já fazia de qualquer jeito. O peso era ênfase redundante que só aparecia como ruído.
Os dois bugs têm a mesma forma. Um valor que parece razoável no abstrato, boardSize para o canvas, 600 para o peso, se comporta diferente quando uma regra concreta de plataforma toca nele: bordas encolhem content-boxes, e o Android arredonda pesos de fonte. Nenhum é exótico. Os dois são invisíveis até você conhecer a regra ou medir o resultado.
O hábito que vale manter
Nada disso exigiu ferramenta sofisticada. O script de medição eram poucas linhas de Python sobre um PNG. O adb já estava conectado. O loop inteiro foi: mudar uma coisa, gerar um APK estilo release, instalar, tirar um screenshot, medir, comparar com o número anterior.
O que fez funcionar foi recusar deixar “parece certo” ou “parece meio torto” fechar a frase. Os dois são opinião. 54,6% é fato, e 50,1% é um fato melhor. Uma vez que você tem números, um conserto que não os move está te dizendo algo, e um conserto que passa do ponto está te dizendo outra coisa.
Se levar uma coisa daqui: quando a UI parece levemente errada e a explicação óbvia é sobre o próprio conteúdo, verifique se algo no layout mudou em silêncio o espaço onde o conteúdo vive. Uma borda num container overflow-hidden é um ótimo lugar para começar a olhar, porque muda um tamanho sem mudar um único pixel visível até o que está dentro transbordar.
O tabuleiro está centralizado agora, e desta vez eu consigo provar em vez de discutir.