Animações de primavera no Vue

Olá Habr!

Há muito tempo, queria adicionar animações do Spring a qualquer projeto. Mas fiz isso apenas para projetos React usando react-spring, porque não sabia de mais nada.

Mas, finalmente, decidi descobrir como tudo funciona e escrever minha implementação!

Se você também quiser usar as animações da Spring em todos os lugares, vá abaixo do gato. Lá você encontrará alguma teoria, implementando o Spring em JS puro e implementando a animação do Spring no Vue usando components e composition-api.


TL; DR


Implementando o Spring Animation em JS: https://playcode.io/590645/ .

Você precisa adicionar animações do Spring ao seu projeto - pegue o Spring da biblioteca Popmotion .

Você precisa adicionar animações do Spring ao seu projeto Vue - pegue o Spring da biblioteca Popmotion e escreva um wrapper sobre ele.

Para que servem as animações de primavera?


As animações css convencionais na web usam uma duração e uma função para calcular a posição periodicamente.

As animações de primavera usam uma abordagem diferente - física da primavera.

Para animação, definimos as propriedades físicas do sistema: a massa da mola, o coeficiente de elasticidade da mola, o coeficiente de atrito do meio. Depois, esticamos a mola para a distância que precisamos animar e a liberamos.

Nos projetos de front-end, vejo essas vantagens nas animações do Spring:

1. Você pode definir a velocidade inicial da animação.

Se o site processar furtos com os dedos ou arrastar com o mouse, as animações de objetos parecerão mais naturais se você as definir com a velocidade inicial, como velocidade de furto ou velocidade do mouse.


Animação CSS que não retém a velocidade do mouse


Animação de mola, na qual a velocidade do mouse é transmitida para a primavera

2. Você pode alterar o ponto final da animação

Se, por exemplo, você deseja animar um objeto para o cursor do mouse, não pode ajudar com CSS - quando você atualiza a posição do cursor, a animação é inicializada novamente, o que causará empurrões é visível.

Não existe esse problema com as animações de mola - se você precisar alterar o ponto final da animação, esticamos ou compactamos a mola no recuo desejado.


Um exemplo de animação de um elemento para um cursor do mouse; topo - animação CSS, parte inferior - animação de primavera

Física da primavera


Pegue uma mola comum com uma carga. Ela ainda está em repouso.

Puxe-a um pouco para baixo. Uma força elástica (F=kΔX), que busca retornar a mola à sua posição original. Quando liberamos a primavera, ela começará a balançar ao longo do sinusóide:


Adicione força de atrito. Seja diretamente proporcional à velocidade da mola (F=αV) Nesse caso, as oscilações decairão com o tempo:


Queremos que nossas animações de primavera sejam animadas da mesma maneira que uma mola oscila quando exposta à elasticidade e ao atrito.

Consideramos a posição da primavera para animação


No contexto das animações, a diferença entre o valor inicial e o final da animação é a tensão inicial da primavera. X0.

Antes do início da animação, recebemos: massa da primaveram, coeficiente de elasticidade da mola k, coeficiente de atrito do meio αtensão da mola X0velocidade inicial V0.

A posição da animação é calculada várias dezenas de vezes por segundo. O intervalo de tempo entre os cálculos da animação é chamadoΔt.

Durante cada novo cálculo, temos a velocidade da molaV e tensão da mola Xno último intervalo de animação. Para o primeiro cálculo da animaçãoX=X0, V=V0.

Começamos a calcular a posição da animação para o intervalo!

De acordo com a segunda lei de Newton, a soma de todas as forças aplicadas é igual à massa do corpo multiplicada por sua aceleração:

F=ma


Duas forças atuam em uma mola - força elástica e força de atrito:

F+F=ma


Vamos anotar as forças com mais detalhes e encontrar a aceleração:

kXαV=ma


a=kXαVm


Depois disso, podemos encontrar uma nova velocidade. É igual à soma da velocidade no intervalo de animação anterior e a aceleração multiplicada pelo intervalo de tempo:

V=V+aΔt


Conhecendo a velocidade, você pode encontrar a nova posição da mola:

X=X+VΔt



Temos a posição correta, mostre-a ao usuário!

Uma vez exibido, inicie um novo intervalo de animação. Para um novo intervaloX=X, V=V.

Vale a pena parar a animação quandoX e Vtornam-se muito pequenas - neste momento as oscilações da primavera são quase invisíveis.

JS Spring Animation


Temos as fórmulas necessárias, resta escrever nossa implementação:

class Spring {
  constructor({ mass, tension, friction, initVelocity, from, to, onUpdate }) {
    this.mass = mass;                 //  
    this.tension = tension;           //  
    this.friction = friction;         //  
    this.initVelocity = initVelocity; //  

    this.from = from;                 //    
    this.to = to;                     //    
    this.onUpdate = onUpdate;         // ,    
  }

  startAnimation() {
    const callDoAnimationTick = () => {
      const isFinished = this.doAnimationTick();

      if (isFinished) {
        return;
      }

      this.nextTick = window.requestAnimationFrame(callDoAnimationTick);
    };

    callDoAnimationTick();
  }

  stopAnimation() {
    const { nextTick } = this;

    if (nextTick) {
      window.cancelAnimationFrame(nextTick);
    }

    this.isFinished = true;
  }

  doAnimationTick() {
    const {
      mass, tension, friction, initVelocity, from, to,
      previousTimestamp, prevVelocity, prevValue, onUpdate,
    } = this;

    //  Δt
    const currentTimestamp = performance.now();
    const deltaT = (currentTimestamp - (previousTimestamp || currentTimestamp))
      / 1000;
    //   Δt 46 
    const normalizedDeltaT = Math.min(deltaT, 0.046);

    let prevSafeVelocity = prevVelocity || initVelocity || 0;
    let prevSafeValue = prevValue || from;

    //   ,    
    const springRestoringForce = -1 * tension * (prevSafeValue - to);
    const dampingForce = -1 * prevSafeVelocity * friction;
    const acceleration = (springRestoringForce + dampingForce) / mass;

    //       
    const newVelocity = prevSafeVelocity + acceleration * normalizedDeltaT;
    const newValue  = prevSafeValue + newVelocity * normalizedDeltaT;

    //    
    const precision = 0.001;
    const isFinished = Math.abs(newVelocity) < precision
      && Math.abs(newValue - to) < precision;

    onUpdate({ value: newValue, isFinished });

    this.previousTimestamp = currentTimestamp;
    this.prevValue = newValue;
    this.prevVelocity = newVelocity;
    this.isFinished = isFinished;

    return isFinished;
  }
}

Sandbox com código de classe Spring e um exemplo de uso:
https://playcode.io/590645

Melhoria menor na animação de primavera


Há um pequeno problema com a nossa classe Spring - Δtserá diferente a cada vez com novos começos da animação.

Aberto em um telefone celular antigo -Δtserá igual a 46 milissegundos, nosso limite máximo. Aberto em um computador poderoso -Δtserá de 16 a 17 milissegundos.

MudandoΔtsignifica que a duração da animação e as alterações no valor animado serão ligeiramente diferentes a cada vez.

Para impedir que isto aconteça, nós podemos fazer isso:

FaçaΔtcomo um valor fixo. Com um novo intervalo de animação, teremos que calcular quanto tempo se passou desde o último intervalo e quantos valores fixos estão neleΔt. Se o tempo não é divisível porΔt, transfira o restante para o próximo intervalo de animação.

Então calculamosX e Vquantas vezes obtivemos valores fixos. Último valorXmostrando ao usuário.

Exemplo:

Δttomemos como 1 milissegundo, 32 milissegundos passados ​​entre o último e o novo intervalo de animação.

Temos que calcular a física 32 vezes,X e V; últimoXdeve ser mostrado ao usuário.

É assim que o método doAnimationTick se parecerá:

class Spring {
  /* ... */

  doAnimationTick() {
    const {
      mass, tension, friction, initVelocity, from, to,
      previousTimestamp, prevVelocity, prevValue, onUpdate,
    } = this;

    const currentTimestamp = performance.now();
    const fractionalDiff = currentTimestamp - (previousTimestamp || currentTimestamp);
    const naturalDiffPart = Math.floor(fractionalDiff);
    const decimalDiffPart = fractionalDiff % 1;
    const normalizedDiff = Math.min(naturalDiffPart, 46);

    let safeVelocity = prevVelocity || initVelocity || 0;
    let safeValue = prevValue || from;

    //     1-  
    for (let i = 0; i < normalizedDiff; i++) {
      const springRestoringForce = -1 * tension * (safeValue - to);
      const dampingForce = -1 * safeVelocity * friction;
      const acceleration = (springRestoringForce + dampingForce) / mass;

      safeVelocity = safeVelocity + acceleration / 1000;
      safeValue  = safeValue + safeVelocity / 1000;
    }

    const precision = 0.001;
    const isFinished = Math.abs(safeVelocity) < precision
      && Math.abs(safeValue - to) < precision;

    onUpdate({ value: safeValue, isFinished });

    //       ,
    //      
    this.previousTimestamp = currentTimestamp - decimalDiffPart;
    this.prevValue = safeValue;
    this.prevVelocity = safeVelocity;
    this.isFinished = isFinished;

    return isFinished;
  }
}

Este método de cálculo da física usa a mola de reação, neste artigo você pode ler mais.

Sobre o coeficiente de atenuação e o período de oscilação intrínseca


Observando as propriedades do sistema - a massa da carga, o coeficiente de elasticidade e o coeficiente de atrito do meio - não está totalmente claro como a mola se comporta. Ao criar animações Spring, essas variáveis ​​são selecionadas aleatoriamente até que o programador esteja satisfeito com a "elasticidade" da animação.

No entanto, na física existem algumas variáveis ​​para uma mola amortecida; olhando para elas, você pode dizer como a mola se comporta.

Para encontrá-los, voltemos à nossa equação da segunda lei de Newton:

kXαV=ma


Nós a escrevemos como uma equação diferencial:

kxαdxdt=md2xdt2


Pode ser reescrito como:

d2xdt2+2ζω0dxdt+ω02x=0,


Onde ω0=km- a frequência das oscilações da mola sem ter em conta a força de atrito, ζ=α2mk- coeficiente de atenuação.

Se resolvermos esta equação, obteremos várias funçõesx(t)que determinam a posição da mola versus o tempo. Colocarei a solução embaixo do spoiler para não esticar muito o artigo, vamos direto ao resultado.

Solução de equação
, :
https://www.youtube.com/watch?v=uI2xt8nTOlQ

:

x+2ζω0x+ω02x=0,ω0>0,ζ0


:

g2+2ζω0g+ω02=0


D=(2ζω0)24ω02=4ω02(ζ21)



:

1. D>0

D>0ζ>1.

D>0:

x(t)=C1er1t+C2er2t,


r1,2— , :

r1,2=2ζω0±4ω02(ζ21)2=ω0(ζ±ζ21)


:

x(t)=C1eω0β1t+C2eω0β2t,


β1,2=ζ±ζ21.

, 0.

2. D=0

D=0ζ=1.

D=0:

x(t)=(C1+C2t)ert,


r— , :

r=ζω0


:

x(t)=(C1+C2t)eζω0t



, 0. , D>0.

, , , 0 , D>0. , ?

3. D<0

D<0ζ<1.

D<0:

x(t)=eαt(C1cos(βt)+C2sin(βt))


αβ— , :

r1,2=α±βi=ω0ζ±ω0ζ21)=ω0ζ±ω01ζ2i


α=ω0ζ,β=ω01ζ2


:

x(t)=eω0ζt(C1cos(ω01ζ2t)+C2sin(ω01ζ2t))



, 0.

ζ, eω0ζt0, .. .

ζ=0.


At ζ<1obtemos uma equação que descreve as vibrações amortecidas de uma mola. Menosζ, menor a força de atrito e as vibrações mais visíveis.

Atζ=1obtemos a equação com atrito crítico, ou seja, a equação na qual o sistema retorna à posição de equilíbrio sem hesitação, da maneira mais rápida.

Atζ>1obtemos uma equação na qual o sistema retorna à posição de equilíbrio sem hesitação. Isso será mais lento do que comζ=1; com aumentoζ a taxa de convergência para a posição de equilíbrio diminui.


Exemplos de animações para vários ζ. Velocidade de animação reduzida por 4 vezes.

Para alémζ, há outra característica compreensível das oscilações da mola - o período das oscilações da mola sem levar em consideração o atrito:

T=2πmk



Temos dois parâmetros:

ζ, que determina quanto a animação flutuará.

Valores possíveisζ0 a 1.

menosζ, as vibrações mais visíveis que a mola terá. Em 0, o sistema não terá atrito, a mola irá oscilar sem atenuação; em 1, a mola convergirá para a posição de equilíbrio sem hesitação.

Valores maiores que 1 não são necessários para nós - eles funcionam como 1, apenas em vão arrastam nossa animação.

Tque dirá quanto tempo a animação vai durar.

Deve-se ter em mente o parâmetroTfala sobre a duração de uma única vibração da mola sem levar em conta a força de atrito, e não sobre a duração total da animação.

Agora podemos derivar o coeficiente de elasticidade e o coeficiente de atrito do meio, sabendoζ e Te assumindo que m=1:

ζ=α2mk,T=2πmk


k=(2πT)2,α=4πζT



Implementamos essa lógica no código:

const getSpringValues = ({ dampingRatio, period }) => {
  const mass = 1;
  const tension = Math.pow(2 * Math.PI / period, 2);
  const friction = 4 * Math.PI * dampingRatio / period;

  return {
    mass,
    tension,
    friction,
  };
};

//  

new Spring({
  ...getSpringValues({
    dampingRatio: 0.3, //   - 
    period: 0.6, //   600 
  }),
  from: 0,
  to: 20,
  onUpdate: () => { /* ... */ },
});

new Spring({
  ...getSpringValues({
    dampingRatio: 1, //   
    period: 0.2, //   200 
  }),
  from: 0,
  to: 20,
  onUpdate: () => { /* ... */ },
});

Agora, temos um método getSpringValues ​​que aceita o coeficiente de atenuação legível por humanos ζe período de oscilação Te retorna a massa da mola, o coeficiente de atrito e o coeficiente de elasticidade.

Este método está na sandbox no link abaixo, você pode tentar usá-lo em vez das propriedades usuais do sistema:
https://playcode.io/590645

Vue Spring Animation


Para usar convenientemente as animações do Spring no Vue, você pode fazer duas coisas: escrever um componente de wrapper ou o método de composição-API .

Animação de primavera através do componente


Vamos escrever um componente que abstraia o uso da classe Spring.

Antes de escrever, imagine como queremos usá-lo:

<template>
  <div>
    <SpringWrapper
      :mass="1"
      :tension="170"
      :friction="14"
      :animationProps="{ x, y }" 
      :animate="true" 
      v-slot="{ x: newX, y: newY }"
    >
      <div :style="{ transform: `translate(${newX}px, ${newY}px)` }" />
    </SpringWrapper>
  </div>
</template>

<script>
import SpringWrapper from 'path/to/SpringWrapper';

export default {
  components: {
    SpringWrapper,
  },

  data() {
    return {
      x: 0,
      y: 0,
    };
  },

  mounted() {
    setTimeout(() => {
      this.x = 100;
      this.y = 100;
    }, 1000);
  },
};
</script>

Nós gostaríamos de:

  • Você pode definir as propriedades da primavera
  • No parâmetro animationProps, foi possível especificar os campos que queremos animar
  • O parâmetro animate passou o valor se animationProps deve ser animado
  • Sempre que os campos mudam em animationProps, eles são alterados usando a animação Spring e transferidos para o slot com escopo definido

Para funcionalidade completa, o componente ainda deve poder alterar a velocidade da mola, mas não faremos isso para não complicar o código.

Começamos o desenvolvimento:

const SpringWrapper = {
  props: {
    friction: { type: Number, default: 10 },
    tension: { type: Number, default: 270 },
    mass: { type: Number, default: 1 },
    animationProps: { type: Object, required: true },
    animate: { type: Boolean, required: true },
  },

  data: () => ({
    //    
    actualAnimationProps: null,
  }),

  created() {
    this.actualAnimationProps = this.animationProps;
    // ,     Spring-
    this._springs = {};
  },

  watch: {
    animationProps: {
      deep: true,

      //    animationProps
      handler(animationProps) {
        const {
          friction, tension, mass,
          animate, actualAnimationProps, _springs,
        } = this;

        //     ,    
        if (!animate) {
          this.actualAnimationProps = animationProps;

          return;
        }

        //      
        Object.entries(animationProps).forEach((([key, value]) => {
          const _spring = _springs[key];
          const actualValue = actualAnimationProps[key];

          //      Spring-,
          //      
          if (_spring && !_spring.isFinished) {
            _spring.to = value;

            return;
          }

          const spring = new Spring({
            friction,
            tension,
            mass,
            initVelocity: 0,
            from: actualValue,
            to: value,
            onUpdate: ({ value }) => {
              actualAnimationProps[key] = value;
            },
          });

          spring.startAnimation();

          _springs[key] = spring;
        }));
      }
    },

    animate(val) {
      const { _springs } = this;

      //     ,
      //    Spring-
      if (!val) {
        Object.values(_springs).forEach((spring) => {
          spring.stopAnimation();
        })

        this.actualAnimationProps = this.animationProps;
      }
    },
  },

  render() {
    const { $scopedSlots, actualAnimationProps } = this;

    //     scopedSlot
    return $scopedSlots.default(actualAnimationProps)[0];
  },
};

Sandbox com um componente e um exemplo de uso:
https://playcode.io/591686/

Animação de primavera pelo método de composição-API


Para usar o composition-api, que aparecerá no Vue 3.0, precisamos do pacote composition-api .

Adicione a nós mesmos:

npm i @vue/composition-api

import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api';

Vue.use(VueCompositionApi);

Agora vamos pensar em como gostaríamos de ver nosso método de animação:

<template>
  <div>
    <div :style="{ transform: `translate(${x}px, ${y}px)` }" />
  </div>
</template>

<script>
import useSpring from 'path/to/useSpring';

export default {
  setup() {
    const { x, y, animate } = useSpring({
      mass: 1, 
      tension: 170, 
      friction: 10,
      x: 0,
      y: 0,
    });

    setTimeout(() => {
      x.value = 100;
      y.value = 100;
    }, 1000);

    return {
      x,
      y,
      animate,
    };
  },
};
</script>

Nós gostaríamos de:

  • Você pode definir as propriedades da primavera
  • A função pegou um objeto com os campos que queremos animar como entrada.
  • A função retornou invólucros computados. Os setters aceitarão os valores que queremos animar como entrada; getters retornará um valor animado
  • A função retornou a variável animada, que será responsável por executar ou não a animação.

Como no caso do componente, não adicionaremos personalização da velocidade da mola para não complicar o código.

Começamos a fazer um método:

import { reactive, computed, ref, watch } from '@vue/composition-api';
import { Spring } from '@cag-animate/core';

const useSpring = (initialProps) => {
  const {
    mass, tension, friction,
    animate: initialAnimate,
    ...restInitialProps
  } = initialProps;

  //  ,      
  const actualProps = reactive(restInitialProps);

  const springs = {};
  const mirrorProps = {};

  Object.keys(initialProps).forEach(key => {
    //     computed-
    // 
    // Getter     actualProps
    // Setter  Spring-
    mirrorProps[key] = computed({
      get: () => actualProps[key],
      set: val => {
        const _spring = springs[key];
        const actualValue = actualProps[key];

        if (!mirrorProps.animate.value) {
          actualProps[key] = val;

          return
        }

        if (_spring && !_spring.isFinished) {
          _spring.to = val;

          return;
        }

        const spring = new Spring({
          friction,
          tension,
          mass,
          initVelocity: 0,
          from: actualValue,
          to: val,
          onUpdate: ({ value }) => {
            actualProps[key] = value;
          },
        });

        spring.startAnimation();

        springs[key] = spring;
      },
    });
  });

  mirrorProps.animate = ref(initialAnimate || true);

  watch(() => mirrorProps.animate.value, (val) => {
    if (!val) {
      Object.entries(springs).forEach(([key, spring]) => {
        spring.stopAnimation();
        actualProps[key] = spring.to;
      });
    }
  });

  return mirrorProps;
};

export default useSpring;

Sandbox com o método composition-api e um exemplo de uso:
https://playcode.io/591812/

A última palavra


No artigo, examinamos os recursos básicos das animações do Spring e os implementamos no Vue. Mas ainda temos espaço para melhorias - nos componentes do Vue, você pode adicionar a capacidade de adicionar novos campos após a inicialização, adicionar uma alteração na velocidade da primavera.

É verdade que escrever seu Spring em projetos reais não é necessário: a biblioteca Popmotion já possui uma implementação pronta do Spring . Você pode escrever um invólucro para o Vue, como fizemos acima.

Obrigado por ler até o fim!

Materiais usados



All Articles