← cd /blog

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.