Spring-анимации во Vue

Привет, Хабр!

Мне давно хотелось добавлять в любые проекты Spring-анимации. Но делал я это только для React-проектов с помощью react-spring, так как не знал ничего другого.

Но наконец я решил разобраться, как оно все устроено и написать свою реализацию!

Если вы тоже хотите использовать Spring-анимации везде, заходите под кат. Там вы найдете немного теории, реализацию Spring на чистом JS и внедрение Spring-анимации во Vue с помощью компонентов и composition-api.

l9hygnaxaeynr_d1qniwquyeeti.gif

TL; DR


Реализация Spring-анимации на JS: https://playcode.io/590645/

Нужно добавить Spring-анимации в свой проект — возьмите Spring из библиотеки Popmotion.

Нужно добавить Spring-анимации в свой Vue-проект — возьмите Spring из библиотеки Popmotion и напишите себе обертку над ним.

Для чего нужны Spring-анимации


Обычные css-анимации в вебе используют длительность и функцию расчета положения от времени.

Spring-анимации используют другой подход — физику пружины.

Для анимации мы задаем физические свойства системы: массу пружины, коэффициент упругости пружины, коэффициент трения среды. Затем мы растягиваем пружину на то расстояние, которое нам нужно проанимировать, и отпускаем ее.

Во фронтенд-проектах я вижу такие плюсы у Spring-анимаций:

1. Можно задать начальную скорость анимации

Если на сайте обрабатываются свайпы пальцами или перетаскивание мышкой, то анимации объектов будут выглядеть более натурально, если задать им начальную скорость как скорость свайпа или скорость мыши.

8yvpgxyzruyevt27xoplqduykhe.gif
CSS-анимация, у которой не сохраняется скорость мыши

ipndnjyuk5rncqiv7cvonq1msj0.gif
Spring-анимация, у которой пружине передается скорость мыши

2. Можно изменять конечную точку анимации

Если вы, например, хотите проанимировать объект до курсора мыши, то CSS’ом тут не поможешь — при обновлении положения курсора анимация инициализируется снова, из-за чего будет видно дерганье.

Со Spring-анимациями такой проблемы нет — если нужно поменять конечную точку анимации, то растягиваем или сжимаем пружину на нужный отступ.

tcxefm9ddifq-xwnsvb75swdgbo.gif
Пример анимации элемента к курсору мыши; сверху — CSS-анимация, снизу — Spring-анимация

Физика пружины


Возьмем обычную пружину с грузом. Она пока находится в состоянии покоя.

Потянем ее немного вниз. На пружину начинает действовать сила упругости ($\vec{F_{уп}} = -k\Delta X$), которая стремится вернуть пружину в начальное положение. Когда мы отпустим пружину, она станет качаться по синусоиде:

gc5epibe3dyanqlaal7oo9ozte8.gif

Добавим силу трения. Пускай она будет прямо пропорциональна скорости пружины ($\vec{F_{тр}} = -\alpha\vec{V}$). В таком случае колебания со временем станут затухать:

awfpvxyfnw0hnhxuve1o5ygm2jc.gif

Мы хотим, чтобы наши Spring-анимации анимировались так же, как колеблется пружина при воздействии силы упругости и силы трения.

Считаем положение пружины для анимации


В контексте анимаций, разница между начальным и конечным значением анимации — это начальное растяжение пружины $\vec{X_0}$.

Перед началом анимации нам даны: масса пружины $m$, коэффициент упругости пружины $k$, коэффициент трения среды $\alpha$, растяжение пружины $\vec{X_0}$, начальная скорость $\vec{V_0}$.

Положение анимации рассчитывается несколько десятков раз в секунду. Временной интервал между расчетами анимации назовем $\Delta t$.

Во время каждого нового расчета у нас есть скорость пружины $\vec{V_{пр}}$ и растяжение пружины $\vec{X_{пр}}$ на прошлом интервале анимации. Для первого расчета анимации $\vec{X_{пр}}=\vec{X_0}$, $\vec{V_{пр}}=\vec{V_0}$.

Начинаем считать положение анимации для интервала!

По второму закону Ньютона, сумма всех приложенных сил равна массе тела, умноженному на его ускорение:

$\vec{F}=m\vec{a}$


На пружину действуют две силы — сила упругости и сила трения:

$\vec{F_{уп}} + \vec{F_{тр}}=m\vec{a}$


Распишем подробнее силы и найдем ускорение:

$-k\vec{X_{пр}} - \alpha\vec{V_{пр}}=m\vec{a}$

$\vec{a} = \dfrac{-k\vec{X_{пр}} - \alpha\vec{V_{пр}}}{m}$


После этого мы можем найти новую скорость. Она равна сумме скорости на предыдущем интервале анимации и ускорению, умноженному на временной интервал:

$\vec{V_{нв}}=\vec{V_{пр}} + \vec{a}*\Delta t$


Зная скорость, можно найти новое положение пружины:

$\vec{X_{нв}}=\vec{X_{пр}} + \vec{V_{нв}}*\Delta t$

Мы получили нужное положение, отображаем его пользователю!

Как только отобразили, начинаем новый интервал анимации. Для нового интервала $\vec{X_{пр}}=\vec{X_{нв}}$, $\vec{V_{пр}}=\vec{V_{нв}}$.

Анимацию стоит остановить, когда $\|\vec{X_{нв}}\|$ и $\|\vec{V_{нв}}\|$ становятся очень маленькими — в этот момент колебания пружины почти не видно.

Spring-анимация на JS


У нас есть нужные формулы, осталось написать свою реализацию:

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

Песочница с кодом Spring-класса и примером его использования:
https://playcode.io/590645

Небольшое улучшение Spring-анимации


С нашим классом Spring есть небольшая проблема — $\Delta t$ будет каждый раз разный при новых запусках анимации.

Откроете на старом мобильнике — $\Delta t$ будет равен 46 миллисекундам, нашему максимальному ограничению. Откроете на мощном компьютере — $\Delta t$ будет равняться 16–17 миллисекундам.

Меняющийся $\Delta t$ означает то, что длительность анимации и изменения анимируемого значения будут каждый раз немного разные.

Чтобы такого не происходило, можем поступить так:

Возьмем $\Delta t$ как фиксированное значение. При новом анимационном интервале мы должны будем вычислить, сколько времени прошло с прошлого интервала и сколько в нем фиксированных значений $\Delta t$. Если время не делится без остатка на $\Delta t$, то переносим остаток на следующий анимационный интервал.

Затем вычисляем $\vec{X_{нв}}$ и $\vec{V_{нв}}$ столько раз, сколько у нас получилось фиксированных значений. Последнее значение $\vec{X_{нв}}$ показываем пользователю.

Пример:

$\Delta t$ возьмем как 1 миллисекунда, между прошлым и новым анимационным интервалом прошло 32 миллисекунды.

Мы должны 32 раза рассчитать физику, $\vec{X_{нв}}$ и $\vec{V_{нв}}$; последний $\vec{X_{нв}}$ надо показать пользователю.

Вот так будет выглядеть метод doAnimationTick:

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

Таким методом расчета физики пользуется react-spring, в этой статьей можно прочитать подробнее.

Про коэффициент затухания и собственный период колебаний


Глядя на свойства системы — массу груза, коэффициент упругости и коэффициент трения среды — совсем непонятно, как ведет себя пружина. При создании Spring-анимаций эти переменные подбираются рандомно, пока программиста не удовлетворит «пружинистость» анимации.

Однако в физике для пружины с затуханием есть пара переменных, при взгляде на которые можно сказать, как ведет себя пружина.

Чтобы их найти, вернемся к нашему уравнению второго закона Ньютона:

$-k\vec{X_{пр}} - \alpha\vec{V_{пр}}=m\vec{a}$


Напишем его как дифференциальное уравнение:

$-kx - \alpha\frac{dx}{dt}=m\frac{d^2x}{dt^2}$


Его можно переписать как:

$\frac{d^2x}{dt^2} + 2\zeta\omega_{0}\frac{dx}{dt} + \omega_{0}^2x = 0,$


где $\omega_{0} = \sqrt\frac{k}{m}$ — частота колебаний пружины без учета силы трения, $\zeta = \frac{\alpha}{2\sqrt{mk}}$ — коэффициент затухания.

Если решить это уравнение, то мы получим несколько функций $x(t)$, которые определяют положение пружины от времени. Решение сложу под спойлер, чтобы не растягивать статью слишком сильно, перейдем сразу к результату.

Решение уравнения
Кто хочет решить его сам, вот хорошее видео:
https://www.youtube.com/watch? v=uI2xt8nTOlQ

Нам дано линейное дифференциальное уравнение второго порядка:

$x'' + 2 \zeta\omega_0x' + \omega_0^2x = 0, \omega_0 >0, \zeta \geq 0$» /></p><br />Для его решения надо найти дискриминант у этого уравнения: <p><img src=

$D = (2\zeta\omega_0)^2 - 4\omega_0^2 = 4\omega_0^2(\zeta^2 - 1)$

У данного уравнения будет три решения:

1. Решение при $D > 0$» /></p><p><img src=


где $r_{1,2} $ — корни квадратного уравнения, написанного выше:

$r_{1,2} = \frac{-2\zeta\omega_0 \pm \sqrt{4\omega_0^2(\zeta^2 - 1)}}{2} = -\omega_0(\zeta \pm \sqrt{\zeta^2 - 1})$


Получившееся решение:

$x(t) = C_1e^{-\omega_0\beta_1t} + C_2e^{-\omega_0\beta_2t},$


где $\beta_{1, 2} = \zeta \pm \sqrt{\zeta_2 - 1}$.

Получилась функция без гармонических колебаний, стремящаяся к 0.

2. Решение при $D = 0$

$D = 0$ мы получим при $\zeta = 1$.

Общее решение для $D = 0$ выглядит так:

$x(t) = (C_1 + C_2t)e^{rt},$


где $r$ — единственный корень квадратного уравнения, написанного выше:

$r = - \zeta\omega_0$


Получившееся решение:

$x(t) = (C_1 + C_2t)e^{- \zeta\omega_0t}$

Получилась функция без гармонических колебаний, стремящаяся к 0. Она будет стремиться к нулю быстрее, чем при $D > 0$» />.</p><p>Правда, у меня не получилось придумать, как доказать, что эта функция сходится к 0 быстрее, чем при <img src=

$D < 0$ мы получим при $\zeta < 1$.

Общее решение для $D < 0$ выглядит так:

$x(t) = e^{\alpha t}(C_1cos(\beta t) + C_2 * sin(\beta t))$


$\alpha$ и $\beta$ — это вещественная и мнимая части комплексных решений квадратного уравнения, написанного вначале:

$r{1,2} = \alpha \pm \beta i = -\omega_0\zeta \pm\omega_0\sqrt{\zeta^2 - 1}) = -\omega_0\zeta \pm \omega_0\sqrt{1 - \zeta^2}i$

$\alpha = -\omega_0\zeta, \beta = \omega_0\sqrt{1 - \zeta^2}$


Получившееся решение:

$x(t) = e^{-\omega_0\zeta t}(C_1cos(\omega_0\sqrt{1 - \zeta^2} t) + C_2sin(\omega_0\sqrt{1 - \zeta^2} t))$

Получилось уравнение затухающих колебаний, стремящихся к 0.

Чем меньше $\zeta$, тем медленнее $e^{-\omega_0\zeta t}$ приближается к 0, т.е. будет больше видимых колебаний.

При $\zeta = 0$ затухания колебаний не будет.

При $\zeta < 1$ мы получим уравнение, которое описывает затухающие колебания пружины. Чем меньше $\zeta$, тем ниже сила трения и больше видимых колебаний.

При $\zeta = 1$ мы получим уравнение с критическим трением, т.е. уравнение, при котором система возвращается к положению равновесия без колебаний, самым быстрым способом.

При $\zeta > 1$» /> мы получим уравнение, при котором система возвращается к положению равновесия без колебаний. Это будет происходить медленнее, чем при <img src=; с увеличением $\zeta$ уменьшается скорость схождения к положению равновесия.

847faem7ezjmrfr2ai4xmxq12mc.gif


Примеры анимаций при различных $\zeta$. Скорость анимации уменьшена в 4 раза

Помимо $\zeta$, есть еще одна понятная характеристика колебания пружины — период колебаний пружины без учета трения:

$T = 2\pi\sqrt{\frac{m}{k}}$

Мы получили два параметра:

$\zeta$, который определяет, насколько сильно анимация будет колебаться.

Возможные значения $\zeta$ от 0 до 1.

Чем меньше $\zeta$, тем больше видимых колебаний будет у пружины. При 0 система не будет иметь силу трения, пружина будет колебаться без затухания; при 1 пружина будет сходиться к положению равновесия без колебаний.

Значения больше 1 нам не нужны — они работают как 1, только зря затягивают нашу анимацию.

$T$, который будет говорить, насколько долго будет длиться анимация.

Стоит иметь в виду, параметр $T$ говорит о длительности одного колебания пружины без учета силы трения, а не об общей длительности анимации.

Теперь мы можем вывести коэффициент упругости и коэффициент трения среды, зная $\zeta$ и $T$ и предполагая, что $ m=1 $:

$\zeta = \frac{\alpha}{2\sqrt{mk}}, T = 2\pi\sqrt{\frac{m}{k}}$

$k = (\frac{2\pi}{T})^2, \alpha = \frac{4\pi\zeta}{T}$

Реализуем эту логику в коде:

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

Теперь мы имеем метод getSpringValues, который принимает понятные для человека коэффициент затухания $\zeta$ и период колебаний $T$ и возвращает массу пружины, коэффициент трения и коэффициент упругости.

Этот метод есть в песочнице по ссылке ниже, можете попробовать использовать его вместо обычных свойств системы:
https://playcode.io/590645

Spring-анимация на Vue


Чтобы удобно использовать Spring-анимации во Vue, можно поступить двумя способами: написать компонент-обертку или метод composition-api.

Spring-анимация через компонент


Давайте напишем компонент, который абстрагирует в себе использование Spring-класса.

Прежде чем писать его, представим как мы хотим его использовать:




Нам бы хотелось, чтобы:

  • Можно было задать свойства пружины
  • В параметре animationProps можно было указать те поля, которые мы хотим проанимировать
  • В параметре animate передавалось значение, надо ли анимировать animationProps
  • При любом изменении полей в animationProps они менялись с помощью Spring-анимации и передавались внутрь scoped-слота

Для полной функциональности компонент должен еще уметь изменять скорость пружины, но мы это не станем делать, чтобы не усложнять код.

Начинаем разработку:

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

Песочница с компонентом и примером его использования:
https://playcode.io/591686/

Spring-анимация через метод composition-api


Для того, чтобы использовать composition-api, который появится во Vue 3.0, нам понадобится пакет composition-api.

Добавим его себе:

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

Vue.use(VueCompositionApi);

Теперь подумаем, каким бы мы хотели видеть наш метод для анимации:




Нам бы хотелось, чтобы:

  • Можно было задать свойства пружины
  • Функция принимала на вход объект с полями, которые мы хотим проанимировать
  • Функция возвращала computed-обертки. Setter’ы будут принимать на вход значения, которые мы хотим проанимировать; getter’ы будут возвращать проанимированное значение
  • Функция возвращала animate-переменную, которая будет отвечать за то, надо нам проигрывать анимацию или нет

Как и в случае с компонентом, не будем добавлять кастомизацию скорости пружины, чтобы не усложнять код.

Начинаем делать метод:

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;

Песочница с методом composition-api и примером его использования:
https://playcode.io/591812/

Последнее слово


В статье мы рассмотрели базовые особенности Spring-анимаций и внедрили их во Vue. Но у нас остался простор для доработок — во Vue-компоненты можно добавить возможность добавления новых полей после инициализации, добавить изменение скорости пружины.

Правда, писать в реальных проектах свой Spring совсем не обязательно: в библиотеке Popmotion уже есть готовая реализация Spring. Для Vue можно написать обертку, как мы сделали выше.

Спасибо, что дочитали до конца!

Использованные материалы


© Habrahabr.ru