De 700ms de tela parada a uma tab bar instantânea
Você toca numa aba. Nada se mexe. Você espera. Meio segundo depois, a pílula desliza até onde você tocou. A essa altura você já ficou na dúvida se o toque pegou.
Era esse o estado de uma tab bar segmentada numa tela inicial em que eu trabalho. Três abas no topo, uma pílula marcando a ativa. Coloquei logs de performance na tela, percorri a interação e li os timestamps. O intervalo entre o toque e o momento em que a pílula começava a se mover passava de 700ms. Não a duração da animação. O atraso antes dela começar.
Uma UI já começa a parecer travada acima de 100ms. Acima de 300ms, você chama de lenta. Setecentos milissegundos é outra categoria de errado.
O setup antigo
A tela original usava um navegador de top-tabs popular do ecossistema React Navigation. Ele te dá troca de abas, páginas com swipe, customização de header, tudo pronto. O custo é que as trocas passam pelo ciclo de vida da navegação: montagem, desmontagem, eventos de foco, transições de tela. Você toca numa aba e a thread de JS tem um monte de burocracia pra mastigar antes de qualquer pixel se mover.
A biblioteca não está fazendo nada errado. Ela faz muita coisa, e numa tela com conteúdo pesado por aba, esse trabalho fica entre você e a animação.
A reformulação
Eu não precisava de um navegador. Eu precisava de um pager. Uma área horizontal com swipe, três páginas e uma pílula que sabe onde está.
O react-native-pager-view faz isso. É uma camada fina sobre o pager nativo da plataforma (ViewPager2 no Android, baseado em UIScrollView no iOS). Quando você dá swipe ou chama setPage, o scroll roda nativamente. A thread de JS não é consultada.
A segunda peça é o Reanimated, especificamente os worklets: pequenas funções JavaScript compiladas pra rodar na thread de UI. Um worklet consegue ler um shared value, fazer contas e escrever um estilo 60 ou 120 vezes por segundo sem nunca pedir permissão pra thread de JS.
O scroll nativo alimenta um shared value. Um worklet lê. A pílula segue. A thread de JS fica fora do caminho crítico da animação, então nada que ela faça consegue deixar a interação lenta.
A pílula que segue o scroll
O núcleo conceitual é mais ou menos assim:
const offset = useSharedValue(0);
const onPageScroll = (e) => {
'worklet';
offset.value = e.position + e.offset; // contínuo 0..N-1
};
const pillStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value * TAB_WIDTH }],
}));offset é um shared value, legível pelas duas threads. O evento de page-scroll do pager nativo dispara continuamente durante um arrasto ou transição programática, e escreve nele a partir da thread de UI. O estilo animado lê na thread de UI. A pílula se move com o seu dedo. Não depois, não de forma assíncrona. Com.
É esse o truque inteiro. O resto é decoração.
Toque e swipe, de graça
Dirigir a pílula pelo offset do scroll (contínuo) em vez do índice da página (discreto) te dá as duas interações de uma vez.
No swipe, o pager nativo emite eventos de scroll o caminho todo e a pílula segue o seu gesto em tempo real. No toque, eu chamo setPage de forma imperativa. O pager nativo anima a transição sozinho, emite os mesmos eventos de scroll, e a pílula segue essa animação do mesmo jeito. A pílula não sabe qual dos dois a moveu. Ela rastreia o offset.
Dois caminhos de animação colapsam numa única fonte da verdade. Menos bugs, menos código, mais consistência.
O problema do texto invisível
Depois que a animação da pílula ficou suave, apareceu um bug novo. A cor de fundo da pílula era igual à cor do texto das abas inativas. Conforme a pílula deslizava sobre uma aba inativa, o texto sumia, e reaparecia depois que a pílula passava.
A correção tinha que caber no mesmo orçamento de animação. Sem trabalho extra de JS, sem thrash de estado. Interpolação, na thread de UI:
const labelStyle = useAnimatedStyle(() => {
const distance = Math.abs(offset.value - tabIndex);
const color = interpolateColor(
distance,
[0, 1], // 0 = pílula em cima, 1 = pílula totalmente longe
[ACTIVE, INACTIVE],
);
return { color };
});Cada label calcula a própria distância até a pílula, em unidades contínuas, e escolhe uma cor ao longo do gradiente entre ativo e inativo. Quando a pílula está centralizada sobre uma aba, o texto veste a cor ativa, que contrasta com a pílula. Conforme a pílula desliza pra fora, o texto faz um crossfade de volta pra cor inativa. A transição parece suave porque é contínua: não tem nenhum frame em que o texto está "na cor errada". É a cor certa pra onde a pílula está agora.
É esse o tipo de detalhe que ninguém coloca nas release notes, e é a diferença entre uma UI que você projetou e uma que você só montou.
O que os logs disseram depois
Rodei de novo os mesmos logs de performance depois da reescrita. O intervalo de "toque até início da animação" deixou de ser mensurável de forma significativa. A animação começa no mesmo frame do toque. A pílula se move enquanto seu dedo ainda está saindo da tela.
Mesma tela, mesmo conteúdo, mesmas três abas. O trabalho só acontece em outro lugar agora, e a maior parte dele nem acontece.
A lição que eu fico reaprendendo
Quando algo parece lento na thread de JS, meu primeiro instinto é deixar a thread de JS mais rápida: memoizar, adiar, fazer debounce, dividir. Às vezes isso compensa. Na maioria das vezes, eu chego mais longe tirando a thread de JS do caminho crítico. Primitivos nativos pra aquilo que código nativo faz bem. Worklets pra matemática da animação. A thread de JS pro que só ela pode fazer.
Toca a aba. A pílula se move. É essa a régua.