Criar animações de expansão e recolhimento com bom desempenho

Stephen McGruer
Stephen McGruer

Texto longo, leia o resumo

Use transformações de escala ao animar clipes. É possível impedir que os elementos filhos sejam esticados e distorcidos durante a animação usando a escala inversa.

Já postamos atualizações sobre como criar efeitos de paralaxe e rolagens infinitas com bom desempenho. Neste post, vamos analisar o que é necessário para criar animações de clipe eficientes. Se você quiser conferir uma demonstração, confira o repositório do GitHub de elementos de interface de exemplo.

Por exemplo, um menu de expansão:

Algumas opções para criar isso têm um desempenho melhor do que outras.

Errado: animar largura e altura em um elemento de contêiner

Você pode usar um pouco de CSS para animar a largura e a altura no elemento do contêiner.

.menu {
  overflow: hidden;
  width: 350px;
  height: 600px;
  transition: width 600ms ease-out, height 600ms ease-out;
}

.menu--collapsed {
  width: 200px;
  height: 60px;
}

O problema imediato dessa abordagem é que ela exige a animação de width e height. Essas propriedades exigem o cálculo do layout e pintam os resultados em cada frame da animação, o que pode ser muito caro e normalmente faz com que você perca 60 fps. Se isso for uma novidade para você, leia nossos guias Desempenho de renderização e confira mais informações sobre como funciona o processo de renderização.

Ruim: usar as propriedades de clipe CSS ou cut-path

Uma alternativa para animar width e height pode ser usar a propriedade clip (agora descontinuada) para animar o efeito de expansão e retração. Ou, se preferir, use clip-path. No entanto, o uso de clip-path é menos compatível do que clip. Mas o clip foi descontinuado. Certo. Mas não se desespere, essa não é a solução que você queria.

.menu {
  position: absolute;
  clip: rect(0px 112px 175px 0px);
  transition: clip 600ms ease-out;
}

.menu--collapsed {
  clip: rect(0px 70px 34px 0px);
}

Embora seja melhor do que animar o width e o height do elemento de menu, a desvantagem dessa abordagem é que ela ainda aciona a pintura. Além disso, a propriedade clip, se você seguir esse caminho, exige que o elemento em que ela está operando seja posicionado de forma absoluta ou fixa, o que pode requerer um pouco mais de esforço.

Bom: animações de escalas

Como esse efeito envolve algo que fica maior e menor, é possível usar uma transformação de escala. Essa é uma ótima notícia, porque a mudança de transformações é algo que não requer layout ou pintura e que o navegador pode transferir para a GPU, o que significa que o efeito é acelerado e tem uma probabilidade significativamente maior de atingir 60 fps.

A desvantagem dessa abordagem, como a maioria das coisas no desempenho de renderização, é que ela requer um pouco de configuração. Mas vale a pena!

Etapa 1: calcular os estados inicial e final

Com uma abordagem que usa animações de escala, a primeira etapa é ler os elementos que informam o tamanho que o menu precisa ter quando está fechado e aberto. Em algumas situações, talvez não seja possível receber essas duas informações de uma só vez. Talvez seja necessário alternar algumas classes para ler os vários estados do componente. No entanto, se você precisar fazer isso, tenha cuidado: getBoundingClientRect() (ou offsetWidth e offsetHeight) força o navegador a executar estilos e transmissões de layout se os estilos tiverem mudado desde a última execução.

function calculateCollapsedScale () {
    // The menu title can act as the marker for the collapsed state.
    const collapsed = menuTitle.getBoundingClientRect();

    // Whereas the menu as a whole (title plus items) can act as
    // a proxy for the expanded state.
    const expanded = menu.getBoundingClientRect();
    return {
    x: collapsed.width / expanded.width,
    y: collapsed.height / expanded.height
    };
}

No caso de algo como um menu, podemos fazer a suposição razoável de que ele vai começar na escala natural (1, 1). Essa escala natural representa o estado expandido, o que significa que você precisa animar de uma versão reduzida (que foi calculada acima) de volta para essa escala natural.

Mas não para por aí. Certamente isso também dimensionaria o conteúdo do menu, não é mesmo? Bem, como você pode ver abaixo, sim.

Então, o que você pode fazer a respeito? Você pode aplicar uma counter-transform ao conteúdo. Por exemplo, se o contêiner for reduzido a 1/5 do tamanho normal, será possível aumentar o conteúdo em 5x para evitar que o conteúdo seja comprimido. Há duas coisas a serem observadas:

  1. A contratransformação também é uma operação de escala. Isso é bom porque também pode ser acelerado, assim como a animação no contêiner. Talvez seja necessário garantir que os elementos animados tenham a própria camada de compositor (ativando a GPU para ajudar). Para isso, você pode adicionar will-change: transform ao elemento ou, se precisar oferecer suporte a navegadores mais antigos, backface-visiblity: hidden.

  2. A contratransformação precisa ser calculada por frame. É aqui que as coisas podem ficar um pouco mais complicadas, porque, supondo que a animação esteja no CSS e use uma função de transição, a própria transição precisa ser anulada ao animar a contra-transformação. No entanto, calcular a curva inversa para, digamos, cubic-bezier(0, 0, 0.3, 1) não é tão óbvio.

Pode ser tentador animar o efeito usando JavaScript. Afinal, você pode usar uma equação de transição para calcular os valores de escala e de contra-escala por frame. A desvantagem de qualquer animação baseada em JavaScript é o que acontece quando a linha de execução principal, em que o JavaScript é executado, está ocupada com outra tarefa. A resposta curta é que a animação pode falhar ou parar completamente, o que não é ótimo para a UX.

Etapa 2: criar animações CSS dinamicamente

A solução, que pode parecer estranha no início, é criar uma animação com keyframes usando nossa própria função de suavização de forma dinâmica e injetá-la na página para uso pelo menu. Agradecemos ao engenheiro do Chrome Robert Flack por apontar isso. O principal benefício disso é que uma animação com keyframes que transforma transformações pode ser executada no compositor, o que significa que ela não é afetada por tarefas na linha de execução principal.

Para fazer a animação de keyframe, passamos de 0 a 100 e calculamos quais valores de escala seriam necessários para o elemento e o conteúdo dele. Eles podem ser reduzidos a uma string, que pode ser injetada na página como um elemento de estilo. A injeção de estilos vai causar uma passagem de Recalcular estilos na página, que é um trabalho adicional que o navegador precisa fazer, mas que só será feito uma vez, quando o componente for inicializado.

function createKeyframeAnimation () {
    // Figure out the size of the element when collapsed.
    let {x, y} = calculateCollapsedScale();
    let animation = '';
    let inverseAnimation = '';

    for (let step = 0; step <= 100; step++) {
    // Remap the step value to an eased one.
    let easedStep = ease(step / 100);

    // Calculate the scale of the element.
    const xScale = x + (1 - x) * easedStep;
    const yScale = y + (1 - y) * easedStep;

    animation += `${step}% {
        transform: scale(${xScale}, ${yScale});
    }`;

    // And now the inverse for the contents.
    const invXScale = 1 / xScale;
    const invYScale = 1 / yScale;
    inverseAnimation += `${step}% {
        transform: scale(${invXScale}, ${invYScale});
    }`;

    }

    return `
    @keyframes menuAnimation {
    ${animation}
    }

    @keyframes menuContentsAnimation {
    ${inverseAnimation}
    }`;
}

Os curiosos podem estar se perguntando sobre a função ease() dentro do loop for. Você pode usar algo como isso para mapear valores de 0 a 1 para um equivalente simplificado.

function ease (v, pow=4) {
  return 1 - Math.pow(1 - v, pow);
}

Você também pode usar a pesquisa do Google para simular como isso será. Muito útil. Se você precisar de outras equações de transição, confira Tween.js de Soledad Penadés, que contém uma pilha delas.

Etapa 3: ativar as animações CSS

Com essas animações criadas e incorporadas à página em JavaScript, a etapa final é alternar as classes que ativam as animações.

.menu--expanded {
  animation-name: menuAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

.menu__contents--expanded {
  animation-name: menuContentsAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

Isso faz com que as animações criadas na etapa anterior sejam executadas. Como as animações pré-renderizadas já são suavizadas, a função de temporização precisa ser definida como linear. Caso contrário, a transição entre cada frame-chave será suave, o que vai parecer muito estranho.

Para retrair o elemento, há duas opções: atualizar a animação CSS para que ela seja executada de forma invertida. Isso vai funcionar bem, mas a "sensação" da animação será invertida. Portanto, se você usou uma curva de facilidade, a inversão será facilitada, o que vai deixar a animação lenta. Uma solução mais adequada é criar um segundo par de animações para reduzir o elemento. Elas podem ser criadas da mesma forma que as animações de frame-chave de expansão, mas com valores de início e fim trocados.

const xScale = 1 + (x - 1) * easedStep;
const yScale = 1 + (y - 1) * easedStep;

Uma versão mais avançada: revelações circulares

Também é possível usar essa técnica para fazer animações circulares de expansão e fechamento.

Os princípios são basicamente os mesmos da versão anterior, em que você dimensiona um elemento e dimensiona os filhos imediatos dele. Nesse caso, o elemento que está sendo dimensionado tem um border-radius de 50%, tornando-o circular, e é envolvido por outro elemento que tem overflow: hidden, o que significa que você não vê o círculo se expandir fora dos limites do elemento.

Aviso sobre essa variante específica: o Chrome tem texto desfocado em telas de DPI baixo durante a animação devido a erros de arredondamento causados pela escala e contra-escala do texto. Se você tiver interesse nos detalhes, há um bug registrado que você pode marcar e seguir.

O código do efeito de expansão circular pode ser encontrado no repositório do GitHub (link em inglês).

Conclusões

Então, aqui está uma maneira de fazer animações de clipe com bom desempenho usando transformações de escala. Em um mundo perfeito, seria ótimo ver animações de clipe aceleradas (há um bug do Chromium para isso feito por Jake Archibald), mas até chegarmos lá, tenha cuidado ao animar clip ou clip-path e evite animar width ou height.

Também seria útil usar animações da Web para efeitos como esse, porque elas têm uma API JavaScript, mas podem ser executadas na linha de execução do compositor se você animar apenas transform e opacity. Infelizmente, o suporte a animações da Web não é ótimo, mas você pode usar o aprimoramento progressivo para usá-las se elas estiverem disponíveis.

if ('animate' in HTMLElement.prototype) {
    // Animate with Web Animations.
} else {
    // Fall back to generated CSS Animations or JS.
}

Até que isso mude, embora seja possível usar bibliotecas baseadas em JavaScript para fazer a animação, talvez você descubra um desempenho mais confiável produzindo uma animação CSS e usando-a. Da mesma forma, se o app já usa JavaScript para as animações, é melhor ser consistente com a base de código atual.

Se você quiser conferir o código desse efeito, consulte o repositório de amostras de elementos de interface do GitHub e, como sempre, deixe um comentário abaixo.