Анимации в мире состояний
Многие уже научились строить чистые интерфейсы и писать «undo-redo» в несколько строчек. Но как быть с анимациями? Часто их обходят стороной, и они не всегда вписываются в подход (state) ↦ DOM. Есть отличные решения вроде React Motion, но что если вам нужно делать сложные анимации или работать с Canvas, используя физический движок?
В нашем тексте рассказывается, как работать с анимациям в React-приложениях, и сравнивается несколько подходов (D3, React-Motion, «грязные компоненты»). А также о том, как «запускать» анимации в Redux-приложениях. Материал основан на расшифровке доклада Алексея Тактарова с нашей декабрьской конференции HolyJS 2017 Moscow. Прилагаем заодно видеозапись этого доклада:
Осторожно, трафик: под катом много картинок и гифок (сами понимаете, материал про анимации).
Я хотел бы начать с истории. Если бы вы оказались в Древнем Риме примерно в первом веке до нашей эры, то могли бы встретить Витрувия.
Витрувий — это один из знаменитейших архитекторов того времени. Он написал 10 томов об архитектуре. Его трудами вдохновлялись многие. Но самое интересное — в одной из книг он вывел три главных качества, которыми должна обладать хорошая архитектура: прочность, польза и красота.
Мне кажется, эту триаду можно найти и в дизайне вещей, которыми мы пользуемся. К чему все это? Мы — разработчики — пишем приложения, сайты. Делаем вещи, которыми пользуются люди. Поэтому мы тоже, как мне кажется, должны взглянуть на те правила, которые были выведены две тысячи лет назад. Ведь приложения, которые мы делаем, должны решать конкретные задачи людей.
Я думаю, что вся суть в обратной связи. Это ключевой момент. И в какой-то степени обратная связь — это анимация, переходы между состояниями.
Далее я буду говорить про анимацию в stateful-приложениях на примере React.
Паттерны анимаций на примере демо из реальных проектов
Прежде чем мы поговорим про React, давайте подумаем, как в целом работают анимации в браузерах, и что это такое.
В идеальном мире анимации должны работать плавно. Наивно полагать, что их можно делать, например, с помощью setinterval или settimeout. И здесь есть два заблуждения.
Заблуждение №1
Мы не правы. Потому что мы не можем использовать setTimeout для анимации. Ведь setTimeout не будет гарантировать того, что ваша функция действительно сработает в указанный промежуток времени. Это может приводить к таким эффектам, как наложение кадров. То есть вы будете думать, что ваша анимация придет через 16 миллисекунд, что примерно соответствует 60 кадрам в секунду, но на самом деле сработает больше, и этот долг будет накапливаться и накапливаться.
К счастью, есть функция, которая позволяет бороться с этой проблемой — requestAnimationFrame. Она гарантирует, что callback отработает в удобное для планировщика браузера время. При этом хочу заметить, что она также может работать через неравные промежутки времени. Если вы захотите использовать requestAnimationFrame, я настоятельно рекомендую посмотреть, на какие браузеры вы ориентируетесь.
Если вы захотите использовать requestAnimationFrame в своих проектах, то вы будете применять что-то вроде этого паттерна. То есть вы объявите функцию, которая будет срабатывать в тот момент, когда должна происходить анимация. Потом вы вызовите requestAnimationFrame — это будет означать, что мы выполним нашу функцию в нужное время.
Дальше интересный момент. В принципе в конце функции мы могли бы вызывать планирование следующего кадра, но таким паттерном является вызов requestAnimationFrame для следующего кадра в самом начале. Так как никто не знает, что может произойти во время выполнения функции. Может она, например, выбросит исключение. Поэтому лучше сразу планировать следующий кадр в самом начале.
// Or use a polyfill:
// import requestAnimationFrame from 'raf'
const { requestAnimationFrame } = window
const animate = () => {
requestAnimationFrame(animate)
// Perform an animation step
x += velocity
}
// Fire it up
requestAnimationFrame(animate)
requestAnimationFrame — незаменимый инструмент для анимаций в браузере.
Далее вы хотите анимировать какое-то свойство с течением времени, например, брать координату некоторого объекта и увеличивать его на какую-то константу, которая по сути является скоростью. И здесь мы с вами столкнулись с заблуждением №2.
Заблуждение №2
Скорость постоянная!
Посмотреть демо
Поскольку функция вызывается с разными промежутками, такое может случиться, если в системе одновременно происходят какие-то другие вычисления, и вы получите другую траекторию, которая не будет вас удовлетворять. Поэтому важно адаптировать то, как работает requestAnimationFrame, основываясь на разнице во времени. И requestAnimationFrame в качестве первого параметра отдает timestamp, который является некой временной меткой с момента открытия браузера. В некоторых браузерах эта метка будет меткой высокой точности.
requestAnimationFrame(timestamp => {
// DOMHighResTimeStamp
// timestamp ~> 30485.84100000153
})
rAF передает в коллбек временную метку с точностью пять микросекунд.
Вообще вы можете рассчитывать, что это будет некоторый double (если используется полифил, то вряд ли), который содержит миллисекунды до запятой, и микросекунды — после.
Эту метку мы можем использовать, чтобы посчитать разницу между этим и предыдущим вызовом функции.
const animate = timestamp => {
const delta = timestamp - prevTimestamp
// Note, it's a function now!
x += velocity(delta)
requestAnimationFrame(animate)
}
Важно считать разницу во времени между вызовами и анимировать значение пропорционально дельте!
Поэтому мы можем ввести некую дельту, которая является разницей текущей метки и предыдущей за вызов, и дальше анимируем нашу переменную, но только при этом она становится функцией. Теперь ту дельту, которая у нас получилась, мы передаем внутрь функции. И мы считаем скорость пропорциональной.
Давайте посмотрим, что из этого получится.
Посмотреть демо
Слева — идеальная анимация, справа — с нашим адаптивным алгоритмом. Можно заметить, что вызовы все равно происходят не через равные промежутки времени. И анимация справа может быть не такой плавной, как слева —, но при этом она имеет ту же форму. То есть мы гарантируем, что она будет выглядеть так же. Это называется пропуск кадров. Выполнять анимацию в заданный промежуток времени — не нормально, а вот пропускать кадры в анимации — совершенно нормально. Поэтому, используя подход дельты, можно сделать высокопроизводительную анимацию.
Небольшой пример
На видео он начинается с 13:09.
requestAnimationFrame — отличный инструмент для произвольных анимаций в браузере. Потому что на нем можно делать любую логику для анимации.
Посмотреть демо
В примере я вывел частицы, которые движутся по некоторому закону. Это симуляция движения птиц во время перелета. Как бы вы делали такой пример в браузере? Скорее всего, вы бы завели функцию tick, которая вызывалась бы в момент просчета анимации. Она делала бы две вещи: считала физику и потом перерисовывала.
const redraw = _ => {
points.forEach(point => {
// make sure `will-change: transform` is set
point.element.style.transform = `
translate3d(${point.x}px, ${point.y}px, 0.0px)
rotate(${point.angle}rad)`
})
}
const tick = ts => {
_lastRaf = requestAnimationFrame(tick)
physicsStep(delta)
redraw(delta)
}
Про перерисовку очень интересно, потому что если вы будете использовать div«ы, как я в примере, то для того, чтобы максимально быстро анимировать их в браузерах, необходимо использовать transform. А вот если бы вы использовали margin, padding или абсолютное позиционирование, то у вас бы ничего не вышло, а если бы работало, то очень медленно.
Очень важно, чтобы у этих элементов стояло свойство «will-change: transform». Это будет гарантировать, что квадратики будут находиться сразу в отдельном слое браузера и потом — композироваться в один общий. Так удастся достичь максимальной производительности. Потом мы пробегаемся по всем точкам и выставляем то, каким будет поворот и позиция точки на экране.
Теперь про stateful-приложения.
Я уверен, что многие работают со stateful-приложениями, даже не подозревая об этом. И, скорее всего, используют подход, который называется Immutable UI.
Что такое Immutable UI? Это когда у вас есть некоторое состояние, и вы его однозначно транслируете в элементы на странице. Обычно это просто render. То есть вы вызываете функцию render, после чего данные, которые у вас есть, транслируются в элементы, и вы получаете состояние на странице. Все здорово! Но потом вы начинаете совершать какие-то действия на странице, водить мышкой или нажимать на клавиши на клавиатуре, тем самым создавая события.
Эти события в вашем приложении приводят к тому, что меняются состояния, а также элементы на странице. То есть по сути наше приложение — это цепочка состояний и соответствующих им состояний элементов на странице. Но проблема в том, что если вы работаете в react или в angular, то, скорее всего, для вас, как для разработчика, это скрыто, потому что когда вы обновляете состояние, то видите на экране перерисованное новое состояние. Поэтому возникает вопрос, что в этом случае делать с анимациями.
Надо отметить, что такой подход Immutable UI имеет много плюсов, потому что вы можете легко его тестировать и делать совершенно безумные вещи.
В примере под спойлером я сделал подобие to do листа (с 18:55, вставить спойлер).
Я менял состояния его пунктов, потом вывел все их одновременно и мог путешествовать во времени назад относительно своих действий. Все это очень здорово.
Самый простой способ сделать анимацию в Immutable-приложениях
Давайте теперь посмотрим на самый простой способ создания анимации в Immutable-приложениях. Это css transitions.
// CSS property
// transition: transform is ease;
// Conditional state change
// Direct style manipulation
CSS анимации в React работают из коробки. Свойство transition + смена состояния = анимация.
Они хороши тем, что подойдут практически для всех задач, которые у вас есть. Работают они точно так же, как наши приложения, основанные на состояниях. Мы определяем свойство, говорим, как мы хотим его анимировать, как оно должно переходить из одного состояния в другое. Кроме того, в React и других библиотеках существуют паттерны для работы с анимацией.
Один из паттернов — замена одного класса на другой. Ну и можно вручную менять стили на элементе. С этим все понятно.
...
Посмотреть демо
Я сделал пример, чтобы продемонстрировать, как работает CSS transitions в React-приложениях. У нас есть набор точек, по определенному закону я перевожу их в координаты и отрисовываю. Это просто массив элементов с разными свойствами. Если я полностью поменяю все данные, изображение изменится мгновенно, и браузер сам дорисует переход.
В ряде случаев CSS transitions ведут себя не очень натурально. Например, если вы запустите одну анимацию и в тот же момент — другую, то ничего не сломается, потому что браузер знает как остановиться и перейти к новому состоянию. Но настоящие программы должны работать не так.
К счастью, в случае с React есть библиотека React-Motion. И на ней я сделал вторую демку (21:25). Мы взяли тот же самый пример: есть массив точек, мы меняем их состояние, но у нас появляется обертка, которая называется Motion. Все происходит так же, а сама библиотека делает переходы уже вручную.
...
Посмотреть демо
React-Motion использует нечто похожее на физический движок. То есть если вы будете накладывать анимацию друг на друга, то заметите, что движения стали подпружиненными, и в целом это выглядит приятно.
В React это выглядит вот так.
{interpolated =>
}
В React-Motion используется отличный паттерн function-as-a-prop. Если у вас есть какой-либо компонент и у него есть тело, его children, то эти children не обязательно должны быть элементами. Они могут быть любым типом данных, в том числе функцией, которая принимает некоторое состояние и возвращает элементы. Такая запись немного пугает новичков, но это работает очень здорово. Вы можете думать, что React-Motion лезет в DOM, меняет некоторые свойства. На самом деле это не так.
Это тот самый requestAnimationFrame, про который мы говорили в самом начале, и на каждом шаге анимации мы просто обновляем их состояние. То есть каждый кадр — это новое состояние, новый render. Удивительно, но это работает.
Посмотреть демо
Один совет — не используйте React-Motion везде.
К сожалению, он подходит не для всех кейсов. Пружинные анимации не ограничиваются по времени, то есть нужно сделать анимацию, которая будет запускаться и работать 10 секунд, то React-Motion тут не помощник.
Так же сложно работать с комплексными анимациями, где сначала анимируется один элемент, а затем второй. В принципе, это можно сделать библиотекой, но будет не очень удобно.
И, наконец, производительность. Все равно ничего не может сравниться с ручной анимацией, то есть. когда мы лезем в элемент и меняем его transform. Поэтому в некоторых случаях React-Motion ведет себя «прожорливо», хоть и работает неплохо.
«Грязные» анимации
Грязные анимации — анимации, в которых не всегда все можно построить на состояниях.
class Dialog extends Component{
componentDidMount(){
const node = findDOMNode(this)
// Or $.animate, anime.js, GSAP, D3 ...
Velocity(node, {scale: 1.5},
{duration: 1000})
}
render(){ ... }
}
Паттерн «анимации на входе» работает через хук componentDidMount и прямой доступ к элементу.
Давайте рассмотрим это на примере диалогового окна. Вы, наверное, встречались с этим, когда вам требовалось показать или скрыть диалоговое окно. Чаще всего это делалось при помощи паттерна componentDidMount, то есть в React есть такой хук, который вызывается после того, как компонент был добавлен в DOM. Но здесь существует проблема: диалоговое окно может уйти из DOM раньше, чем закончится анимация. Поэтому за этим тоже нужно следить.
Если вы работаете с грязными анимациями входа, то есть анимируете что-то на входе, наилучший совет — сохраняйте дескриптор анимации и потом отменяйте ее, если компонент уходит из DOM раньше времени. Такая возможность есть при использовании библиотек Velocity или jQuery animate.
class Dialog extends Component{
componentDidMount(){
const node = findDOMNode(this)
// animate returns a cancellable
// promise-like object
this._anim = animate(node, { ... })
}
componentWillUnmount(){
this._anim && this._anim.cancel()
}
}
То есть тут можно извлечь компонент до того, как закончится анимация.
Посмотреть демо
В нашем примере диалоговое окно сразу пропадает, и у нас в принципе нет возможности анимировать выход. Обычно вы заводите некий флаг, который отвечает за то, показывать окно или нет, и вы опционально рендерите этот компонент. И тут уже не получится сделать анимацию выхода, потому что как только компонент уйдет из DOM, то анимировать его не получится. И с этим нужно что-то делать.
{this.state.showDialog && }
Поэтому давайте напишем обертку, которая будет работать похоже и позволит нам делать анимацию выхода. В нашем случае я назвал ее Animated.
{this.state.showDialog && }
Когда внутри появляются дочерние элементы, мы запускаем анимацию входа, а как только они пропадают, мы делаем анимацию выхода и смотрим на краевые случаи.
Если мы представим, как мог бы работать компонент, то получим вот такую карту. У нас есть четыре состояния, в которых может находиться компонент в определенный момент: когда он анимируется, когда на экране, когда он выходит и когда уже вышел. С первыми двумя состояниями все понятно (entering, entered), но вопрос, что делать с состоянием (exitting), когда компонент выходит? У нас уже нет этих children, которые нам передали. Мы должны что-то нарисовать. Поэтому здесь можно использовать такой трюк, который называется ghostChildren, то есть оставить на элементы и компоненты, пока не сработает анимация.
const element =
// => { type: Dialog, props: { size: 'medium' }, ... }
const element = React.createElement(Dialog, { size: 'medium'})
Что скрывается за JSX?
Когда нам нужно сделать анимацию выхода, то мы берем children, сохраняем, добавляем в state и делаем анимацию выхода. В целом код получается не очень приятным.
Когда мы получаем новые children, то можно увидеть, что они поменялись. В этой функции мы смотрим, в какое состояние мы хотим перейти и какие дополнительные опции принимаем. Самое главное, что, используя ссылку на компонент, мы можем вызвать у него функцию анимации выхода и сделать правильный переход.
componentWillReceiveProps(nextProps){
// Exit transition
if(this.props.children && !nextProps.children){
return this.transitionState(st.EXITING,
{children: this.props.children})
}
}
transitionState(transitionTo, opt = {}){
// .. FSM logic ..
// Wait for `this._content.animateExit()`
}
Компонент-хелпер Animated с поддержкой анимацией выхода.
Давайте посмотрим, что получилось (на видео с 31:33).
Посмотреть демо
Интересно, что если вы будете менять состояния слишком быстро, анимация будет вести себя корректно. Переходы будут анимироваться не до конца и вовремя уходить с экрана.
Но если вы пишете на React, вам не придется делать все то, что сейчас делаем мы сами, потому что можно использовать библиотеку react-transition-group. Раньше она была аддоном React. Мне нравится, что в новой версии появился удобный хелпер, который называется transition. В целом это низкоуровневый компонент, который эмулирует примерно то же самое, что мы сейчас сделали.
import Transition
from 'react-transition-group/Transition'
// `state` is 'entered', 'entering', 'exited' etc.
{state =>
}
React-transition-group@2.0 представляет собой декларативный компонент для анимаций входа/выхода.
Используя так называемые грязные компоненты, можно делать сложные компоненты, в которых происходят изменения состояний.
Посмотреть демо
В очередном примере (на видео с 32:57) я сделал гистограмму, на которой менялись значения. По сути, это большой компонент, в который автоматически приходят состояния из сети и с websocket«ов. Необходимо, чтобы анимация сама выполнялась, поэтому мой компонент выглядит как обычный, но внутри анимирует состояния при помощи грязных анимаций. В данном случае я использовал D3.
Иногда невозможно получить доступ к элементу и нужно использовать Web API, так называемые грязные компоненты — это Canvas и так далее В таком случае можно применить паттерн — перехват ответственности.
render(){
return
}
// Render only once!
shouldComponentUpdate() { return false }
componentWillReceiveProps(nextProps){
if(this.props.color != nextProps.color){
// Animate on canvas...
}
}
С помощью хуков можно полностью перехватить ответственность за рендер. Например, для работы с Canvas, WebGL, WebAudio и так далее.
Итак, вы делаете рендер один раз. Далее вы говорите, что не будете рендерить этот компонент, так как перехватываете ответственность и в специальной функции возвращаете false. Далее в хуке componentWillReceiveProps вы смотрите, какие свойства пришли, поменялись ли они, и выполняете нужную анимацию. Звучит просто, но на практике получается, что анимировать не всегда удобно. Давайте посмотрим, почему.
Я сделал вот такой компонент на WebGL. Это икосаэдр. Я сверху через два свойства передаю ему, = как его крутить по вертикали и горизонтали. И внутри функции componentWillReceiveProps я сравниваю слепок и делаю необходимые трансформации.
Посмотреть демо
Это вращение не совсем натуральное, оно выглядит грубо и сопровождается рывками. Но есть трюк под названием контроллер, который позволяет сделать плавную анимацию.
В чем разница? В том, что раньше мы смотрели, что приходит сверху, и обновляли внутреннее состояние, поэтому разворот был мгновенным. А контроллер работает немного по-другому. Контроллер — понятие из теории управления. В нашем случае это P-контроллер, частный случай PID-контроллера. Эта область, которая управляет руками робота.
В нашем случае это простой контроллер, и его действия основаны на следующем эффекте.
// Limit delta to avoid divergence
const delta = Math.min(100.0, ts - prevTs)
const P = 0.001 + delta
this.x = P + (this.target - x)
P-контроллер удобен для плавных неограниченных по времени анимаций.
У нас есть строка, есть значение (this.x) и нам нужно перевести его в target. Мы смотрим, насколько мы далеко от нужного места, умножаем на коэффициент и двигаемся на эту точку. В целом формула такая же, как и для закона Гука, и для пружин. Я хочу заметить, если вы используете requestAnimationFrame и контроллеры в анимации, то лучше всего, если вы будете добавлять дельту. Ту, которую вы получили между вызовами requestAnimationFrame. Причем в данном случае я ее ограничил, потому что, если вы переключитесь на другую вкладку браузера, а потом вернетесь назад, то у вас будет очень большая дельта. Это приведет к тому, что у вас будут очень большие значения, и пружина сломается. Поэтому мы ограничиваем ее, умножаем на какую-то константу и используем.
Используя паттерн «перехват ответственности», можно работать и с физикой.
Посмотреть демо
Если вы делаете грязные компоненты, то вы должны следить, чтобы:
- Ваши компоненты имели чистый интерфейс.
- Сайд-эффекты были спрятаны
В принципе эти правила работают и в обратную сторону. Например, вы работаете в в stateful-приложении и вам необходимо запускать анимации. Тогда нужно задать некоторый триггер (например, изменение состояния), следить за этим изменением внутри компонента и запускать анимацию.
import { Actuator, actuate } from 'redux-actuator'
// Inside the component
// Where the business logic is
store.dispatch(actuate('animateBadge'))
store.dispatch(actuate('highlighUser', { id: 1 }))
Паттерн удобно использовать в Redux-приложениях, где глобальный стейт — единственный способ коммуникации.
Мы часто работаем с Redux, и для того, чтобы сделать анимацию, иногда приходится делать так, чтобы в приложении анимировались все независимые от нашего текущего модуля вещи.
Я опубликовал небольшую open-source утилиту — redux-actuator. Там есть такой актуатор, который позволяет вызывать события внутри компонентов.
Посмотреть демо
Как это выглядит, можно посмотреть на видео с 40:27. Дело в том, что мы берем некий ключ в нашем состоянии и меняем его на другой. То есть нам нужно сделать так, чтобы состояние действительно поменялось. В случае с актуатором я поступаю следующим образом: беру некий id event«a и делаю его из текущего времени и счетчика для того, чтобы избежать коллизий. И таким образом можно вызывать анимацию.
Посмотреть демо
У вас может возникнуть вопрос, как делать сложные анимации (пример можно посмотреть на видео с 41:27). Скажу так, анимации в коде почти всегда будут выглядеть некрасиво. Главное — сделать правильную декомпозицию. Мы же знаем, как делать анимации выхода, поэтому трюк в том, чтобы правильно разделить ответственность. В этом случае мы берем слой, который уходит, то есть тот, в котором находятся item«ы. Оставляем его на экране, пока идет анимация, и потом применяем технику, которая называется FLIP. В моем примере не совсем FLIP, но принцип тот же. FLIP — это когда вы берете элемент, сразу рисуете то, что хотите получить, как будто мгновенно переходите в роут с превью. Первый роут делаете прозрачным, а второй позиционируете, ставите в DOM, запускаете анимацию. Потом, когда анимация сработала и этот элемент появляется под другим, вы просто подменяете его и убираете его из DOM. Но, к сожалению, в React сейчас нет простой утилиты для того, чтобы это сделать, но это возможно. Главное — сделать правильную декомпозицию.
Сегодня мы с вами рассмотрели несколько подходов к анимации. В основном мы работали в рамках одного компонента. Сначала мы рассмотрели чистые анимации. Они называются так, потому что полностью работают на состояниях. Далее идут грязные анимации: они в принципе выглядят как чистые, но внутри используют доступ в DOM. Наконец, есть третий вид компонентов, которые полностью перехватывают рендер. Когда вам нужно работать с Canvas, WebGL, вы используете подход перехвата рендера. Плюс сложные случаи, которые решаются правильной декомпозицией.
В завершение я бы хотел посоветовать доклады по теме анимации, которые можно посмотреть.
Ссылки
Дополнения к оригинальному докладу:
В React, начиная с версии 16.3, метод componentWillReceiveProps становится deprecated, т.к. новый рендерер его не сможет поддерживать. Обычно этот хук использовался для того, чтобы устанавливать state на основе props, которые передают компоненту. Сейчас команда React советует плавно уходить от componentWillReceiveProps в сторону getDerivedStateFromProps. Но проблема в том, что метод теперь статичный, поэтому если он для трансформации props в state еще будет работать, то для перехвата ответственности точно нет.
Подробнее можно прочитать тут.
Сейчас официального решения для этого случая нет, но его поддержка должна появиться очень скоро, поскольку в npm достаточно пакетов, использующих перехват ответственности. Например, react-canvas.
Существует официальный способ запуска high-performant анимаций в React Native. Узнал про этот API уже после доклада. Он называется
В любом случае это очень интересно спроектированный API, реализация которого в web позволила бы решить вопросы из доклада. Например, отмена анимации при выходе компонента. Или можно было бы описывать сложные случаи, вроде «схватил-потянул-анимация». Будущее веб-анимаций в React точно где-то здесь.
Надеемся, вам пригодится опыт Алексея. А если вы любите смаковать детали разработки на JS так же, как и мы, наверняка вам будут интересны вот эти доклады на нашей конференции HolyJS 2018 Piter, до которой осталась всего пара недель: