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

Stephen McGruer
Stephen McGruer

Texto longo, leia o resumo

Usar transformações de escala ao animar clipes. É possível evitar que os filhos sejam esticados e distorcidos durante a animação ao fazer o contador de redimensionamento.

Anteriormente, postamos atualizações sobre como criar efeitos de paralaxe de alto desempenho e rolagens infinitas. Nesta postagem, vamos explicar o que está envolvido se você quer animações de clipe de alta performance. Se você quiser ver uma demonstração, confira o repositório de elementos da IU de exemplo do GitHub (em inglês).

Veja, por exemplo, um menu de expansão:

Algumas opções para criar esse recurso são mais eficientes que outras.

Ruim: animação de largura e altura em um elemento contêiner

Imagine usar um pouco de CSS para animar a largura e a altura do elemento 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 a exibição dos 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 novidade para você, leia nossos guias Desempenho de renderização, em que você pode conseguir mais informações sobre como o processo de renderização funciona.

Ruim: usar as propriedades de clipe CSS ou de caminho de clipe

Uma alternativa à animação de width e height é usar a propriedade clip (agora descontinuada) para animar o efeito de expansão e recolhimento. Ou, se preferir, você pode usar clip-path. No entanto, o uso de clip-path é menos compatível do que clip. No entanto, o uso de 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 que animar width e height do elemento de menu, a desvantagem dessa abordagem é que ela ainda aciona a pintura. Além disso, a propriedade clip, caso você vá por esse trajeto, exige que o elemento em que ele está operando esteja com posição absoluta ou fixa, o que pode exigir um pouco mais de conversão.

Bom: animar escalas

Como esse efeito envolve algo cada vez maior, é possível usar uma transformação de escala. Isso é ótimo porque a mudança de transformações não requer layout ou pintura, e que o navegador pode entregar à GPU, o que significa que o efeito é acelerado e muito mais provável de atingir 60 fps.

A desvantagem dessa abordagem, como a maioria dos aspectos no desempenho de renderização, é que ela exige um pouco de configuração. Mas vale muito a pena!

Etapa 1: calcular os estados de início e término

Com uma abordagem que usa animações de escala, a primeira etapa é ler elementos que informam o tamanho do menu quando está recolhido e aberto. Pode ser que, em algumas situações, você não consiga acessar as duas informações de uma só vez e precise alternar algumas classes para poder 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 começará em sua escala natural (1, 1). Essa escala natural representa o estado expandido, o que significa que você vai precisar animar de uma versão reduzida (que foi calculada acima) de volta para essa escala natural.

Opa, peraí. Com certeza isso também dimensionaria o conteúdo do menu, não é mesmo? Bem, como você pode ver abaixo, sim.

O que você pode fazer sobre isso? É possível aplicar uma counter- transformada ao conteúdo. Por exemplo, se o contêiner for reduzido a 1/5 do tamanho normal, será possível escalonar o conteúdo counter- cinco vezes para evitar que ele seja comprimido. Há duas coisas a serem observadas sobre isso:

  1. O contador-transformação também é uma operação de escala. Isso é bom porque também pode ser acelerado, assim como a animação no contêiner. Pode ser necessário garantir que os elementos em animação recebam a própria camada de compositor, permitindo que a GPU ajude. Para isso, você pode adicionar will-change: transform ao elemento ou, se precisar oferecer suporte a navegadores mais antigos, backface-visiblity: hidden.

  2. A contra-transformação precisa ser calculada por frame. É aqui que as coisas podem ficar um pouco mais complicadas, porque, supondo que a animação esteja em CSS e use uma função de easing, esse próprio easing precisa ser evitado na animação da transformação do contador. No entanto, calcular a curva inversa para, digamos, cubic-bezier(0, 0, 0.3, 1) não é tão óbvio.

Por isso, pode ser tentador animar o efeito usando JavaScript. Afinal, é possível usar uma equação de easing para calcular os valores de escala e contador por frame. A desvantagem de qualquer animação baseada em JavaScript é o que acontece quando a linha de execução principal (onde o JavaScript é executado) está ocupada com outra tarefa. A resposta curta é que a animação pode travar ou parar completamente, o que não é bom para UX.

Etapa 2: criar animações CSS rapidamente

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

Para criar a animação de frame-chave, vamos de 0 a 100 e calculamos quais valores de escala são necessários para o elemento e o conteúdo dele. Eles podem ser resumidos em uma string, que pode ser injetada na página como um elemento de estilo. A injeção dos estilos causa uma transmissão de recalcular estilos na página, que é um trabalho extra que o navegador precisa realizar, mas isso será feito apenas uma vez quando o componente estiver sendo 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}
    }`;
}

Quem tem curiosidade pode estar se perguntando sobre a função ease() dentro do loop for. Você pode usar algo assim para mapear valores de 0 a 1 para um equivalente facilitado.

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

Também é possível usar a Pesquisa Google para criar um gráfico com a aparência. Útil! Se você precisar de outras equações de easing, confira Tween.js de Soledad Penadés, que contém várias delas.

Etapa 3: ativar as animações CSS

Com essas animações criadas e agrupadas na 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 com bake já são suavizadas, a função de tempo precisa ser definida como linear. Caso contrário, facilita o intervalo entre cada frame-chave, o que pode parecer muito estranho.

Quando se trata de recolher o elemento de volta, há duas opções: atualizar a animação CSS para que seja executada ao contrário em vez de para frente. Isso funcionará bem, mas a "sensação" da animação será invertida. Portanto, se você usou uma curva de desaceleração, o inverso será suavizado em, o que tornará a animação lenta. Uma solução mais adequada é criar um segundo par de animações para recolher o elemento. Eles podem ser criados exatamente da mesma maneira que as animações de expansão dos frames-chave, mas com os valores inicial e final trocados.

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

Uma versão mais avançada: circular revela

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

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

Um 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 devido à escala e à contra-escala do texto. Se quiser saber mais sobre isso, há um bug que você pode marcar com estrela 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, aí está, uma maneira de criar animações de clipe eficientes usando transformações de escala. Em um mundo perfeito, seria ótimo ver as animações de clipe serem aceleradas (há um bug do Chromium 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 Web Animations para efeitos como esse, porque elas têm uma API JavaScript, mas podem ser executadas na linha de execução do compositor se você apenas animar transform e opacity. Infelizmente, o suporte a animações na Web não é bom, embora seja possível usar o aprimoramento progressivo para usá-las, se 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 você possa usar bibliotecas baseadas em JavaScript para fazer a animação, pode descobrir um desempenho mais confiável criando e usando uma animação CSS. Da mesma forma, se o app já depende de JavaScript para as animações, talvez seja melhor ser pelo menos consistente com a base de código existente.

Se você quiser conferir esse efeito no código, consulte o repositório de exemplos de elementos da IU do GitHub (link em inglês) e, como sempre, use os comentários abaixo para compartilhar sua experiência com a gente.