Аддитивная анимация с помощью Web Animations API

Перевод статьи Additive Animation with the Web Animations API с сайта css-tricks.com для CSS-live.ru, автор — Дэн Уилсон

Эти возможности пока что не поддерживаются ни одним стабильным браузером. Однако, всё, о чем я сейчас расскажу, уже есть в Firefox Nightly, и ключевые части есть в Chrome Canary (при включенном флаге «Экспериментальные возможности веб-платформы»), поэтому во время чтения этой статьи я рекомендую использовать один из этих браузеров, чтобы увидеть максимум возможностей в действии.

Какой способ анимации в вебе вы бы ни предпочли, настанет время, когда вам потребуется анимировать одно свойство в отдельных анимациях. К примеру, у вас есть эффект по наведению, масштабирующий изображение, и событие клика, по которому вызывается сдвиг — оба они влияют на transform. По умолчанию эти анимации никак не связаны друг с другом, и только одна применится визуально (поскольку обе влияют на одно CSS-свойство, и последнее значение переопределит первое).

element.animate({
  transform: ['translateY(0)', 'translateY(10px)']
}, 1000);

/* Это полностью переопределится предыдущей анимацией. */
element.animate({
  transform: ['scale(1)', 'scale(1.15)']
}, 1500);

В этом примере с Web Animations API визуально отобразится только вторая анимация, потому что проигрываются они одновременно, но она была объявлена последней

Иногда нам требуются задачи посложнее, когда нужно создать основную анимацию, а затем на основе некоторого изменения в состоянии, связанного с действиями пользователя, плавно изменить анимацию на полпути, не трогая её существующие продолжительность, ключевые кадры или функцию плавности. CSS-анимации и текущий Web Animations API в стабильных браузерах сегодня не способны на такое.

Новая возможность

Спецификация Web Animations вводит свойство composite (и связанное с ним iterationComposite). Значение по умолчанию для composite — 'replace', и его поведение нам уже знакомо за годы, когда активное анимирующееся значение свойства просто заменяет любое ранее заданное значение — будь то из набора правил или из другой анимации.

Значение 'add' — вот где всё становится по-новому.

element.animate({
  transform: ['scale(1)', 'scale(1.5)']
}, {
  duration: 1000,
  fill: 'both'
});
element.animate({
  transform: ['rotate(0deg)', 'rotate(180deg)']
}, {
  duration: 1500,
  fill: 'both',
  composite: 'add'
});

Теперь обе анимации выглядят так, как будто браузер на лету вычисляет соответствующую трансформацию в данной точке во временной шкале элемента, отвечающей за обе трансформации. В нашем примере, 'linear' — функция плавности по умолчанию, и анимации начнутся одновременно, поэтому можно понять, какой transform реально получается в любой отдельно взятой точке. Например:

  • 0ms: scale(1) rotate(0deg)
  • 500ms: scale(1.25) rotate(60deg) (на полпути после первой анимации, 1/3 анимации через секунду)
  • 1000ms: scale(1.5) rotate(120deg)  (в конце первой, 2/3 через секунду)
  • 1500ms: scale(1.5) rotate(180deg) (в конце второй)

See the Pen Animation Composite Add: Smiles by Dan Wilson (@danwilson) on CodePen.

Итак, начнем творить

Отдельная анимация состоит не просто из начального и конечного состояния — она может включать и свою собственную функцию плавности, количество итераций, продолжительность и другие ключевые кадры в середине. Прямо посреди анимации элементу можно добавить дополнительную трансформацию со своими опциями шкалы времени.

See the Pen Add more transform animations by Максим (@psywalker) on CodePen.

В этом примере к одному элементу можно применять множественные анимации, влияющие на свойство transform. Чтобы код примера был понятнее, у нас в каждой анимации будет меняться только одна функция трансформации (например, только scale) от значения по умолчанию (например, scale(1) или transformX(0)) до разумного случайного значения этой же функции, и это будет повторяться бесконечно. Следующая анимация повлияет на другую отдельную функцию с её собственными случайными продолжительностью и функцией плавности.

element.animate(getTransform(), //напр. { transform: ['rotate(0deg), 'rotate(45deg)'] }
{
  duration: getDuration(), //между 1000 и 6000мс
  iterations: Infinity,
  composite: 'add',
  easing: getEasing() //одна из двух опций
});

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

Поскольку у каждой анимации свои временные параметры, то если наложить несколько их друг на друга, итоговое движение вряд ли будет повторяться. Благодаря этому анимация не надоест, даже если смотреть на нее долго.

Поскольку каждая анимация в нашем примере начинается со значения по умолчанию (0 для сдвигов и 1 для масштабирования), ее начало будет плавным. Если вместо этого у нас были бы ключевые кадры вроде { transform: ['scale(.5)', 'scale(.8)'] }, то был бы перескок: только что масштабирования не было — и вдруг ни с того ни с сего анимация начинается с половинного размера.

Как добавляются значения?

Значения трансформации следуют синтаксису из спецификации, и при добавлении трансформации она добавляется в конец списка.

Для анимаций transform А, Б и В результат вычисленного значения transform будет [текущее значение в A] [текущее значение в Б] [текущее значение в В]. Например, представим следующие три анимации:

element.animate({
  transform: ['translateX(0)', 'translateX(10px)']
}, 1000);

element.animate({
  transform: ['translateY(0)', 'translateY(-20px)']
}, { 
  duration:1000,
  composite: 'add'
});

element.animate({
  transform: ['translateX(0)', 'translateX(300px)']
}, { 
  duration:1000,
  composite: 'add'
});

Каждая анимация длится 1 секунду с линейной функцией плавности, так что в середине этих анимаций итоговое значение transform будет translateX(5px) translateY(-10px) translateX(150px). Функции плавности, длительности, задержки и все другие эффекты будут влиять на значение по ходу движения.

Однако, трансформации не единственное, что можно анимировать. Фильтры (hue-rotate(), blur() и т.п.) следуют похожему паттерну, где элементы добавляются в конец списка фильтров.

Некоторые свойства (вроде opacity) используют числа в качестве значения. Здесь числа складываются в общую сумму.

element.animate({
  opacity: [0, .1]
}, 1000);

element.animate({
  opacity: [0, .2]
}, { 
  duration:1000,
  composite: 'add'
});

element.animate({
  opacity: [0, .4]
}, { 
  duration:1000,
  composite: 'add'
});

Поскольку каждая анимация снова длится 1 секунду с линейной функцией плавности, можно рассчитать итоговое значение в любой точке в этой анимации.

  • 0ms: opacity: 0 (0 + 0 + 0)
  • 500ms: opacity: .35 (.05 + .1 + .2)
  • 1000ms: opacity: .7 (.1 + .2 + .4)

Таким образом, если у вас в нескольких анимациях есть ключевые кадры со значением 1, вы не заметите большой разницы. Это максимальное значение, у которого есть видимый эффект, и любая добавка значений сверх этого даст точно такую же картину, что и просто 1.

See the Pen Add more opacity animations by Максим (@psywalker) on CodePen.

Подобно opacity и другим свойствам с числовыми значениями, свойства, которые принимают размеры, проценты или цвета будут также складываться в одно итоговое значение. С цветами нужно помнить, что у них также есть максимальное значение (для rgb() это максимум 255, а для насыщенности/осветления в hsl() — 100%), поэтому результат может упереться в белый цвет. С длинами можно переключаться между единицами (например, px в vmin), как будто это в calc().

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

Работа с режимами заполнения

Если вы не делаете бесконечную анимацию (с помощью composite или нет), то по умолчанию анимация не сохраняет своё конечное состояние по завершении. Свойство fill позволяет изменять это поведение. Если при добавлении небесконечной анимации нужен плавный сдвиг, то, вероятно, вам нужен режим заполнения forwards или both, чтобы гарантировать сохранение конечного состояния.

See the Pen Spiral: Composite Add + Fill Forwards by Максим (@psywalker) on CodePen.

В этом примере анимация спиральной траектории делается с помощью сдвига и вращения. Есть две кнопки, добавляющие новые односекундные анимации с дополнительным маленьким сдвигом. Поскольку у них указано fill: 'forwards', каждый дополнительный сдвиг фактически остаётся частью списка трансформации. Расширяющася (или сужающаяся) спираль плавно адаптируется с каждой настройкой сдвига, поскольку это аддитивная анимация, которая плавно увеличивается от translateX(0) к translateX(<сколько-то>), и останавливается на этом значении.

Накопление анимаций

У нового свойства composite есть третье значение — 'accumulate'. Оно в целом похоже на `add`, но только некоторые типы анимации ведут себя иначе. Взяв всё тот же наш transform, начнём с нового примера, используя 'add', а после обсудим, чем 'accumulate' от него отличается.

element.animate({
  transform: ['translateX(0)', 'translateX(20px)']
}, {
  duration: 1000,
  composite: 'add'
});
element.animate({
  transform: ['translateX(0)', 'translateX(30px)']
}, {
  duration: 1000,
  composite: 'add'
});
element.animate({
  transform: ['scale(1)', 'scale(.5)']
}, {
  duration: 1000,
  composite: 'add'
});

На 1-секундной отметке (конец анимации), фактическое значение будет:

transform: translateX(20px) translateX(30px) scale(.5)

Это визуально сдвинет элемент вправо на 50px, а затем уменьшит его масштаб до половины ширины и высоты.

Если бы вместо этого каждая анимация использовала 'accumulate', тогда результат был бы:

transform: translateX(50px) scale(.5)

Это визуально сдвинет элемент вправо на 50px, а затем уменьшит его масштаб до половины ширины и высоты.

Перепроверять незачем, визуально результат один и тот же — так чем же 'accumulate' отличается?

Технически, при накоплении анимации transform мы уже не обязательно дописываем в конец списка. Если функция трансформации уже есть (такая как translateX() в нашем примере), мы не будем добавлять значение во время начала нашей второй анимации. Вместо этого, внутреннее значение (то есть, значение длины) будет добавлено и помещено в существующую функцию.

Если наши визуальные результаты одинаковые, для чего нужна возможность накапливать внутренние значения?

В случае трансформации, порядок списка функций важен. Трансформация translateX(20px) translateX(30px) scale(.5) отличается от translateX(20px) scale(.5) translateX(30px), поскольку каждая функция влияет на систему координат функций, идущих следом. Когда вы делаете scale(.5) в середине, функции, идущие позже, также будут выполняться в половинном масштабе. Поэтому в этом примере translateX(30px) визуально отобразится, как сдвиг на 15px вправо.

See the Pen Visual Reference: Transform Coordinate Systems by Максим (@psywalker) on CodePen.

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

Накопление для каждой итерации

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

В отличие от composite, у этого свойства есть только два валидных значения:  'replace' (поведение по умолчанию, которое вы уже знаете и любите) и 'accumulate'. С 'accumulate' для списков (как с transform) значения накапливаются по процессу, о котором говорилось выше, а для числовых свойств типа opacity они суммируются.

В качестве исходного примера, визуальный результат для следующих двух анимаций будет одинаковым:

intervals.animate([{ 
  transform: `rotate(0deg) translateX(0vmin)`,
  opacity: 0
}, { 
  transform: `rotate(50deg) translateX(2vmin)`,
  opacity: .5
}], {
  duration: 2000,
  iterations: 2,
  fill: 'forwards',
  iterationComposite: 'accumulate'
});

intervals2.animate([{ 
  transform: `rotate(0deg) translateX(0vmin)`,
  opacity: 0
},{ 
  transform: `rotate(100deg) translateX(4vmin)`,
  opacity: 1
}], {
  duration: 4000,
  iterations: 1,
  fill: 'forwards',
  iterationComposite: 'replace' //Значение по умолчанию
});

Первая анимация только увеличивает свою прозрачность на .5, вращается на 50 градусов и перемещается на 2vmin за 2000 миллисекунд. У неё стоит наше новое значение iterationComposite, и она настроена на две итерации. Поэтому к моменту своего завершения анимация продлится 2 * 2000 миллисекунд и дойдет до непрозрачности 1 (2 * .5), сделает поворот на 100 градусов (2 * 50deg) и сдвинется на 4vmin (2 * 2vmin).

See the Pen Spiral with WAAPI iterationComposite by Максим (@psywalker) on CodePen.

Отлично! Только что мы использовали новое свойство, поддерживающиеся только в Firefox Nightly, чтобы воссоздать то, что уже можно делать с помощью Web Animations API (или CSS)!

Более интересные аспекты iterationComposite заработают при сочетании его с другими возможностями из спецификации Web Animations, которые скоро появятся (и тоже уже есть в Firefox Nightly).

Установка новых параметров для эффекта

Web Animations API, как он поддерживается в стабильных браузерах, в основном на равных с CSS-анимациями, с добавочными приятными мелочами вроде параметра playbackRate и возможности перескакивать/перематывать к разным точкам. Но теперь, объект Animation получает возможность обновлять эффект и опции шкалы времени в уже запущенных анимациях.

See the Pen WAAPI iterationComposite & composite by Максим (@psywalker) on CodePen.

Здесь у нас элемент с двумя анимациями, которые влияют на свойство transform и полагаются на composite: 'add' — одна двигает элемент вдоль экрана по горизонтали, а другая придает ему колебательное движение по вертикали. У этой второй анимации конечная точка на экране чуть выше, чем начальная, и благодаря iterationComposite: 'accumulate' она поднимается всё выше и выше. После восьми итераций анимация заканчивается, разворачивается и следующие восемь итераций идет в обратную сторону, к низу экрана, а там всё начинается заново.

Мы можем регулировать, насколько поднимется траектория анимации, меняя количество итераций на лету. Эти анимации проигрываются бесконечно, но посреди анимации можно поменять количество итераций в выпадающем списке. Если вы, к примеру, переходите от семи итераций к девяти, а текущая при этом шестая, то анимация продолжит работать, как ни в чём не бывало. Однако, вы заметите, что вместо того, чтобы начать двигаться обратно после следующей (седьмой) итерации, она продлится ещё две. Можете также заменить ключевые кадры на новые, и время анимации останется прежним.

animation.effect.timing.iterations = 4;
animation.effect.setKeyframes([
  { transform: 'scale(1)' },
  { transform: 'scale(1.2)' }
]);

Может, изменение анимаций на полпути и не войдёт в вашу ежедневную практику, но поскольку это что-то новое на браузерном уровне, мы будем изучать его возможности, когда функциональность получит более широкую поддержку. Изменение количества итераций может быть удобным в игре, когда пользователь получает бонусный раунд и игра продолжается сверх запланированного. Менять «на лету» ключевые кадры на другие может пригодиться в ситуации, когда пользователь перейдёт от какого-то состояния ошибки к успешному состоянию.

Что нам с этим делать дальше?

Новые варианты composite, а также возможность изменять опции шкалы времени и ключевые кадры, открывают новые двери для анимаций в ответ на какое-то событие или в составе общего сценария. В рабочей группе CSS тоже идут бурные обсуждения по внедрению этой функциональности в CSS, даже вне контекста анимаций — речь о новом способе влияния на каскад. У нас есть время, прежде чем что-то из этого появится в стабильных основных браузерах, но приятно наблюдать появление этих возможностей, и ещё приятнее возможность поэкспериментировать с ними уже сегодня.

P.S. Это тоже может быть интересно:

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Ваш E-mail не будет опубликован

Получать новые комментарии по электронной почте. Вы можете подписаться без комментирования.