Spring animations in Vue

Hello, Habr!

I have long wanted to add Spring animations to any projects. But I did this only for React projects using react-spring, because I did not know anything else.

But finally, I decided to figure out how it all works and write my implementation!

If you also want to use Spring-animations everywhere, go under the cat. There you will find some theory, implementing Spring in pure JS, and implementing Spring animation in Vue using components and composition-api.


TL; DR


Implementing Spring Animation in JS: https://playcode.io/590645/ .

You need to add Spring animations to your project - take Spring from the Popmotion library .

You need to add Spring animations to your Vue project - take Spring from the Popmotion library and write a wrapper over it.

What are Spring Animations for?


Conventional css animations on the web use a duration and a function for calculating position from time to time.

Spring animations use a different approach - spring physics.

For animation, we set the physical properties of the system: the mass of the spring, the coefficient of elasticity of the spring, the coefficient of friction of the medium. Then we stretch the spring to the distance that we need to animate, and release it.

In front-end projects, I see such advantages in Spring animations:

1. You can set the initial animation speed.

If the site processes swipes with your fingers or drag with the mouse, then the object animations will look more natural if you set them with the initial speed like swipe speed or mouse speed.


CSS animation that doesn't retain mouse speed


Spring-animation, in which the speed of the mouse is transmitted to the spring

2. You can change the endpoint of the animation

If, for example, you want to animate an object to the mouse cursor, then you can’t help with CSS - when you update the cursor position, the animation is initialized again, which will cause jerking is visible.

There is no such problem with Spring-animations - if you need to change the end point of the animation, we stretch or compress the spring to the desired indent.


An example of animating an element to a mouse cursor; top - CSS animation, bottom - Spring animation

Spring physics


Take an ordinary spring with a load. She is still at rest.

Pull her down a bit. An elastic force (F=kΔX), which seeks to return the spring to its original position. When we release the spring, it will begin to swing along the sinusoid:


Add friction force. Let it be directly proportional to the speed of the spring (F=αV) In this case, the oscillations will decay over time:


We want our Spring-animations to be animated in the same way that a spring oscillates when exposed to elasticity and friction.

We consider the position of the spring for animation


In the context of animations, the difference between the initial and final value of the animation is the initial tension of the spring X0.

Before the start of the animation, we are given: spring massm, spring coefficient of elasticity k, friction coefficient of the medium αspring tension X0, starting speed V0.

The animation position is calculated several tens of times per second. The time interval between animation calculations is calledΔt.

During each new calculation, we have the spring speedV and spring tension Xon the last animation interval. For the first calculation of animationX=X0, V=V0.

We start to calculate the position of the animation for the interval!

According to Newton’s second law, the sum of all applied forces is equal to the mass of the body multiplied by its acceleration:

F=ma


Two forces act on a spring - elastic force and friction force:

F+F=ma


We will write down the forces in more detail and find the acceleration:

kXαV=ma


a=kXαVm


After that we can find a new speed. It is equal to the sum of the speed in the previous animation interval and the acceleration multiplied by the time interval:

V=V+aΔt


Knowing the speed, you can find the new position of the spring:

X=X+VΔt



We got the right position, display it to the user!

Once displayed, start a new animation interval. For a new intervalX=X, V=V.

The animation is worth stopping whenX and Vbecome very small - at this moment the spring oscillations are almost invisible.

JS Spring Animation


We have the necessary formulas, it remains to write our implementation:

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 with Spring class code and an example of its use:
https://playcode.io/590645

Minor Spring Animation Improvement


There is a small problem with our Spring class - Δtwill be different each time with new starts of the animation.

Open on an old cell phone -Δtwill be equal to 46 milliseconds, our maximum limit. Open on a powerful computer -Δtwill be 16-17 milliseconds.

ChangingΔtmeans that the duration of the animation and changes in the animated value will be slightly different each time.

To prevent this from happening, we can do this:

TakeΔtas a fixed value. With a new animation interval, we will have to calculate how much time has passed since the last interval and how many fixed values ​​are in itΔt. If time is not divisible byΔt, then transfer the remainder to the next animation interval.

Then we calculateX and Vas many times as we got fixed values. Last valueXshowing to the user.

Example:

Δtlet's take as 1 millisecond, 32 milliseconds passed between the last and the new animation interval.

We have to calculate physics 32 times,X and V; lastXmust be shown to the user.

This is what the doAnimationTick method will look like:

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;
  }
}

This method of calculating physics uses react-spring, in this article you can read more.

About attenuation coefficient and intrinsic oscillation period


Looking at the properties of the system - the mass of the load, the coefficient of elasticity and the coefficient of friction of the medium - it is completely unclear how the spring behaves. When creating Spring-animations, these variables are selected randomly until the programmer is satisfied with the "springiness" of the animation.

However, in physics there are a couple of variables for a damped spring, looking at them you can tell how the spring behaves.

To find them, let's return to our equation of Newton’s second law:

kXαV=ma


We write it as a differential equation:

kxαdxdt=md2xdt2


It can be rewritten as:

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


Where ω0=km- the frequency of the oscillations of the spring without taking into account the friction force, ζ=α2mk- attenuation coefficient.

If we solve this equation, then we get several functionsx(t)that determine the position of the spring versus time. I’ll put the solution under the spoiler so as not to stretch the article too much, we will go straight to the result.

Equation solution
, :
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 ζ<1we get an equation that describes the damped vibrations of a spring. Lessζ, the lower the friction force and the more visible vibrations.

Atζ=1we get the equation with critical friction, i.e. the equation in which the system returns to the equilibrium position without hesitation, in the fastest way.

Atζ>1we get an equation in which the system returns to the equilibrium position without hesitation. This will be slower than withζ=1; with increaseζ the rate of convergence to the equilibrium position decreases.


Examples of animations for various ζ. Animation speed reduced by 4 times.

In addition toζ, there is another understandable characteristic of spring oscillations - the period of spring oscillations without taking into account friction:

T=2πmk



We got two parameters:

ζ, which determines how much the animation will fluctuate.

Possible Valuesζ0 to 1.

lessζ, the more visible vibrations the spring will have. At 0, the system will not have friction, the spring will oscillate without attenuation; at 1, the spring will converge to the equilibrium position without hesitation.

Values ​​greater than 1 are not necessary for us - they work as 1, only in vain drag out our animation.

Twhich will say how long the animation will last.

It should be borne in mind the parameterTtalks about the duration of a single vibration of the spring without taking into account the friction force, and not about the total duration of the animation.

Now we can derive the coefficient of elasticity and the coefficient of friction of the medium, knowingζ and Tand assuming that m=1:

ζ=α2mk,T=2πmk


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



We implement this logic in the code:

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: () => { /* ... */ },
});

Now we have a getSpringValues ​​method that accepts human-readable attenuation coefficient ζand period of oscillation Tand returns the mass of the spring, the coefficient of friction and the coefficient of elasticity.

This method is in the sandbox at the link below, you can try to use it instead of the usual system properties:
https://playcode.io/590645

Vue Spring Animation


To conveniently use Spring animations in Vue, you can do two things: write a wrapper component or the composition-api method .

Spring animation through component


Let's write a component that abstracts the use of the Spring class.

Before writing it, imagine how we want to use it:

<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>

We would like to:

  • You could set the properties of the spring
  • In the animationProps parameter, it was possible to specify the fields that we want to animate
  • The animate parameter passed the value whether animationProps should be animated
  • Whenever fields change in animationProps, they are changed using Spring animation and transferred to the scoped slot

For full functionality, the component must still be able to change the speed of the spring, but we will not do this in order not to complicate the code.

We begin development:

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 with a component and an example of its use:
https://playcode.io/591686/

Spring animation via composition-api method


In order to use composition-api, which will appear in Vue 3.0, we need the composition-api package .

Add it to ourselves:

npm i @vue/composition-api

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

Vue.use(VueCompositionApi);

Now let's think about how we would like to see our animation method:

<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>

We would like to:

  • You could set the properties of the spring
  • The function took an object with the fields we want to animate as input.
  • The function returned computed wrappers. Setters will accept the values ​​we want to animate as input; getters will return an animated value
  • The function returned animate variable, which will be responsible for whether we need to play the animation or not.

As in the case of the component, we will not add customization of the spring speed so as not to complicate the code.

We begin to make a method:

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 with composition-api method and an example of its use:
https://playcode.io/591812/

The last word


In the article, we examined the basic features of Spring animations and implemented them in Vue. But we still have room for improvements - in Vue-components you can add the ability to add new fields after initialization, add a change in the speed of the spring.

True, writing your Spring in real projects is not necessary at all: the Popmotion library already has a ready-made Spring implementation . You can write a wrapper for Vue, as we did above.

Thank you for reading to the end!

Used materials



All Articles