Pular para o conteúdo principal

Animações de Carregamento: Outubro

· Leitura de 12 minutos

Décima das doze animações.


Animações de Carregamento (série de 13 partes)
  1. Introdução
  2. Janeiro
  3. Fevereiro
  4. Março
  5. Abril
  6. Maio
  7. Junho
  8. Julho
  9. Agosto
  10. Setembro
  11. Outubro
  12. Novembro
  13. Dezembro

info

Neste experimento, as animações podem não ser renderizadas de forma suave no Firefox.

Animação

Resultado final:

Introdução

Minha ideia inicial era criar uma animação em que o caminho em si fosse animado, e não os círculos.

A primeira implementação, onde o caminho é animado, conforme o planejado, funcionou no Chrome e no Firefox, mas não no Safari. A segunda implementação, onde o caminho não é animado, mas o mesmo efeito é alcançado por outro meio, funcionou nos três navegadores, embora a renderização no Firefox tenha sido a menos suave.

Os testes foram realizados no macOS 14.7.6, com Chrome 141.0.7390.123, Firefox 144.0.2 e Safari 18.5.

Versão 1

info

Em alguns blocos a seguir, omiti elementos e atributos para manter a explicação concisa.

Na versão 1, criei diversos senoides que iniciam em diferentes fases até completar uma volta, então usei o elemento animate para animar o atributo d do elemento path, criando assim a animação do caminho. Em seguida, usei o elemento animateMotion para posicionar os círculos de forma estática no caminho.

Caminho

Implementei um código para gerar os senoides. SEGMENT_AMOUNT = 10 (número de segmentos de linha) e PHASE_AMOUNT = 15 (número de fases ao longo da volta) são valores que encontrei que resultam em curvas e interpolações suaves o suficiente.

Resultado



Código

JavaScript
// voltaconst TAU = 2 * Math.PI; // https://en.wikipedia.org/wiki/Tau_(mathematics)// dimensõesconst WIDTH = 200;const HEIGHT = 200;// senoidesconst AMPLITUDE = HEIGHT / 10; // https://pt.wikipedia.org/wiki/Amplitudeconst SEGMENT_AMOUNT = 10;const PHASE_AMOUNT = 15;// resultadoconst values = { top: "", bottom: "" };for (let i = 0; i < PHASE_AMOUNT; i++) {  const phase = i / (PHASE_AMOUNT - 1);  const lines = { top: "", bottom: "" };  for (let j = 0; j <= SEGMENT_AMOUNT; j++) {    const segment = j / SEGMENT_AMOUNT;    const angle = segment * TAU + phase * TAU;    const command = j === 0 ? "M" : "L";    const x = WIDTH * segment;    const y = {      top: HEIGHT * (2 / 5) + Math.sin(angle) * (AMPLITUDE / 2),      bottom: HEIGHT * (3 / 5) - Math.sin(angle) * (AMPLITUDE / 2),    };    lines.top += `${command} ${x} ${y.top} `;    lines.bottom += `${command} ${x} ${y.bottom} `;  }  values.top += lines.top;  values.bottom += lines.bottom;  if (i < PHASE_AMOUNT - 1) {    values.top += `; `;    values.bottom += `; `;  }}/*{  top: "M 0 80 L 20 85.87785252292473 L 40 89.51056516295154 L 60 89.51056516295154 L 80 85.87785252292473 L 100 80 L 120 74.12214747707527 L 140 70.48943483704846 L 160 70.48943483704846 L 180 74.12214747707526 L 200 80; ...; M 0 75.66116260882441 L 20 81.78556894798636 L 40 87.22794863827392 L 60 89.90949761767935 L 80 88.80595531856738 L 100 84.33883739117559 L 120 78.21443105201364 L 140 72.77205136172608 L 160 70.09050238232065 L 180 71.19404468143262 L 200 75.66116260882441",  bottom: "M 0 120 L 20 114.12214747707527 L 40 110.48943483704846 L 60 110.48943483704846 L 80 114.12214747707527 L 100 120 L 120 125.87785252292473 L 140 129.51056516295154 L 160 129.51056516295154 L 180 125.87785252292474 L 200 120; ...; M 0 120 L 20 114.12214747707527 L 40 110.48943483704846 L 60 110.48943483704846 L 80 114.12214747707526 L 100 120 L 120 125.87785252292473 L 140 129.51056516295154 L 160 129.51056516295154 L 180 125.87785252292474 L 200 120"}*/
SVG
<svg  xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 200 200"  width="200"  height="200">  <defs>    <path id="path_top">      <animate        attributeName="d"        values="values.top"        dur="1500ms"        repeatCount="indefinite"        calcMode="linear"      ></animate>    </path>    <path id="path_bottom">      <animate        attributeName="d"        values="values.bottom"        dur="1500ms"        repeatCount="indefinite"        calcMode="linear"      ></animate>    </path>  </defs>  <rect    width="199"    height="199"    rx="6"    x="0.5"    y="0.5"    fill="white"    stroke-width="1"    stroke="lightgray"  ></rect>  <g fill="none" stroke="lightgray">    <use href="#path_top"></use>    <use href="#path_bottom"></use>  </g></svg>

Exemplo sem interpolação, com SEGMENT_AMOUNT = 5 e PHASE_AMOUNT = 7, para evidenciar como a animação é construída. O número menor de segmentos faz a curva não ser suave, e o menor número de fases faz com que a diferença entre um senoide e outro seja maior.

Círculos

Para posicionar os círculos de forma estática no caminho, usei o elemento animateMotion com keyTimes="0; 1" e keyPoints com dois valores iguais. Ou seja, do início ao fim da animação, eles mantêm a mesma posição.

Posicionei os círculos espaçados igualmente no centro do caminho, com valores 0.3, 0.4, 0.5, 0.6 e 0.7.

Resultado

A animação pode não funcionar no Safari

Código

<svg>  <defs></defs>  <rect></rect>  <g></g>  <g fill="black" stroke="none">    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.3; 0.3" keyTimes="0; 1">        <mpath href="#path_top"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.4; 0.4" keyTimes="0; 1">        <mpath href="#path_top"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.5; 0.5" keyTimes="0; 1">        <mpath href="#path_top"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.6; 0.6" keyTimes="0; 1">        <mpath href="#path_top"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.7; 0.7" keyTimes="0; 1">        <mpath href="#path_top"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.3; 0.3" keyTimes="0; 1">        <mpath href="#path_bottom"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.4; 0.4" keyTimes="0; 1">        <mpath href="#path_bottom"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.5; 0.5" keyTimes="0; 1">        <mpath href="#path_bottom"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.6; 0.6" keyTimes="0; 1">        <mpath href="#path_bottom"></mpath>      </animateMotion>    </ellipse>    <ellipse rx="8" ry="8">      <animateMotion keyPoints="0.7; 0.7" keyTimes="0; 1">        <mpath href="#path_bottom"></mpath>      </animateMotion>    </ellipse>  </g></svg>

Comparação

O Chrome renderizou a animação como eu esperava. O Firefox também renderizou, mas a animação ficou trêmula. O Safari renderizou a animação do caminho, mas não a dos círculos.

Chrome, Firefox e Safari, respectivamente

Versão 2

info

Em alguns blocos a seguir, omiti elementos e atributos para manter a explicação concisa.

Na versão 2, criei dois senoides que completam duas voltas e que se extendem o dobro da largura do retângulo. Então, usei o elemento g para agrupar os senoides, e o elemento animateTransform para mover o grupo da direita para a esquerda, criando assim a animação do caminho. Em seguida, usei o elemento animateMotion para mover os círculos da esquerda para a direita na mesma velocidade que o grupo é movido, o que faz com que os círculos fiquem estáticos.

Caminho

Fiz algumas mudanças no código anterior para gerar os senoides. SEGMENT_AMOUNT = 30 foi o valor que encontrei que resulta em uma curva suave o suficiente.

Resultado



Código

JavaScript
// voltaconst TAU = 2 * Math.PI;// dimensõesconst WIDTH = 200;const HEIGHT = 200;// senoidesconst AMPLITUDE = HEIGHT / 10;const SEGMENT_AMOUNT = 30;// resultadoconst lines = { top: "", bottom: "" };for (let i = 0; i <= SEGMENT_AMOUNT; i++) {  const segment = i / SEGMENT_AMOUNT;  const angle = 2 * TAU * segment;  const command = i === 0 ? "M" : "L";  const x = 2 * WIDTH * segment;  const y = {    top: HEIGHT * (2 / 5) + Math.sin(angle) * (AMPLITUDE / 2),    bottom: HEIGHT * (3 / 5) - Math.sin(angle) * (AMPLITUDE / 2),  };  lines.top += `${command} ${x} ${y.top} `;  lines.bottom += `${command} ${x} ${y.bottom} `;}/*{  top: "M 0 80 ... L 400 80",  bottom: "M 0 120 ... L 400 120"}*/
SVG
<svg  xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 200 200"  width="200"  height="200">  <defs>    <path id="path_top" d="line.top"></path>    <path id="path_bottom" d="line.bottom"></path>  </defs>  <rect    width="199"    height="199"    rx="6"    x="0.5"    y="0.5"    fill="white"    stroke-width="1"    stroke="lightgray"  ></rect>  <g>    <animateTransform      attributeName="transform"      attributeType="XML"      type="translate"      from="0"      to="-200"      dur="1500ms"      repeatCount="indefinite"    ></animateTransform>    <g fill="none" stroke="lightgray">      <use href="#path_top"></use>      <use href="#path_bottom"></use>    </g>  </g></svg>

Ou seja, os senoides têm largura de 400 e se movem de x = 0 para x = -200, repetindo esse movimento indefinidamente.

Círculos

Para posicionar os círculos de forma que parecessem estáticos no caminho, usei o elemento animateMotion com keyTimes="0; 1" e keyPoints "0.15; 0.65", "0.2; 0.7", "0.25; 0.75", "0.3; 0.8" e "0.35; 0.85". Dessa forma, os círculos ficam espaçados igualmente no centro do caminho.

Resultado

Código

<svg>  <defs></defs>  <rect></rect>  <g>    <animateTransform></animateTransform>    <g></g>    <g fill="black" stroke="none">      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.15; 0.65"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.2; 0.7"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.25; 0.75"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.3; 0.8"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.35; 0.85"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.15; 0.65"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.2; 0.7"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.25; 0.75"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.3; 0.8"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.35; 0.85"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>    </g>  </g></svg>

Ou seja, os círculos vão do ponto inicial ao ponto final no mesmo tempo em que o grupo. Comparação entre os círculos com e sem animação, respectivamente:


Comparação

O Chrome e o Safari renderizaram a animação corretamente. O Firefox novamente renderizou com um efeito trêmulo.

Chrome, Firefox e Safari, respectivamente

Conclusão

Este foi um experimento interessante, pois evidenciou limitações na forma como diferentes navegadores interpretam e renderizam animações SVG. Mesmo padrões abertos como o SVG não têm comportamento 100% uniforme entre navegadores, gerando resultados distintos mesmo em animações aparentemente simples.

Durante os testes, notei que:

  • O Safari tende a ter limitações com o elemento animateMotion, especialmente quando usado em conjunto com caminhos animados (path com animate no atributo d).
  • O Firefox sofre mais com problemas de suavidade (stuttering), mesmo quando a animação é suportada. Imagino que pode estar relacionado ao modo como ele interpola valores.
  • O Chrome parece não ter grandes problemas com animações SVG.

O processo também foi útil para entender melhor o equilíbrio entre complexidade e suavidade em animações SVG. Por exemplo, aumentar o número de segmentos (SEGMENT_AMOUNT) e fases (PHASE_AMOUNT) melhora a qualidade visual, mas também eleva o custo de renderização e pode afetar o desempenho em alguns contextos. Outro ponto relevante é como pequenas mudanças, como animar o caminho versus animar o grupo que o contém, podem levar a resultados visualmente idênticos, mas com impacto na compatibilidade e desempenho.

Devido às limitações atuais do Safari, eu não usaria novamente a abordagem da versão 1, que depende diretamente da animação do atributo d do path. A abordagem da versão 2, por outro lado, mostrou-se mais estável e previsível entre navegadores. Dessa forma, escolhi a versão 2 como final.

Resultado



Código

<svg  xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 200 200"  width="200"  height="200">  <defs>    <path      id="path_top"      d="M 0 80 L 13.33 84.06 L 26.66 87.43 L 40 89.51 L 53.33 89.94 L 66.66 88.66 L 80 85.87 L 93.33 82.07 L 106.66 77.92 L 120 74.12 L 133.33 71.33 L 146.66 70.05 L 160 70.48 L 173.33 72.56 L 186.66 75.93 L 200 80 L 213.33 84.06 L 226.66 87.43 L 240 89.51 L 253.33 89.94 L 266.66 88.66 L 280 85.87 L 293.33 82.07 L 306.66 77.92 L 320 74.12 L 333.33 71.33 L 346.66 70.05 L 360 70.48 L 373.33 72.56 L 386.66 75.93 L 400 80"    ></path>    <path      id="path_bottom"      d="M 0 120 L 13.33 115.93 L 26.66 112.56 L 40 110.48 L 53.33 110.05 L 66.66 111.33 L 80 114.12 L 93.33 117.92 L 106.66 122.07 L 120 125.87 L 133.33 128.66 L 146.66 129.94 L 160 129.51 L 173.33 127.43 L 186.66 124.06 L 200 120 L 213.33 115.93 L 226.66 112.56 L 240 110.48 L 253.33 110.05 L 266.66 111.33 L 280 114.12 L 293.33 117.92 L 306.66 122.07 L 320 125.87 L 333.33 128.66 L 346.66 129.94 L 360 129.51 L 373.33 127.43 L 386.66 124.06 L 400 120"    ></path>  </defs>  <rect    width="199"    height="199"    rx="6"    x="0.5"    y="0.5"    fill="white"    stroke-width="1"    stroke="lightgray"  ></rect>  <g>    <animateTransform      attributeName="transform"      attributeType="XML"      type="translate"      from="0"      to="-200"      dur="1500ms"      repeatCount="indefinite"    ></animateTransform>    <g fill="black" stroke="none">      <!-- início: círculo superior 1 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.15; 0.65"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo superior 1 -->      <!-- início: círculo superior 2 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.2; 0.7"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo superior 2 -->      <!-- início: círculo superior 3 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.25; 0.75"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo superior 3 -->      <!-- início: círculo superior 4 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.3; 0.8"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo superior 4 -->      <!-- início: círculo superior 5 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.35; 0.85"          keyTimes="0; 1"        >          <mpath href="#path_top"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo superior 5 -->      <!-- início: círculo inferior 1 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.15; 0.65"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo inferior 1 -->      <!-- início: círculo inferior 2 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.2; 0.7"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo inferior 2 -->      <!-- início: círculo inferior 3 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.25; 0.75"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo inferior 3 -->      <!-- início: círculo inferior 4 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.3; 0.8"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo inferior 4 -->      <!-- início: círculo inferior 5 -->      <ellipse rx="8" ry="8">        <animateMotion          dur="1500ms"          repeatCount="indefinite"          keyPoints="0.35; 0.85"          keyTimes="0; 1"        >          <mpath href="#path_bottom"></mpath>        </animateMotion>      </ellipse>      <!-- fim: círculo inferior 5 -->    </g>  </g></svg>

Leitura recomendada