Производительная анимация сворачивания-разворачивания
13.04.2017

Производительная анимация сворачивания-разворачивания

Оригинал: Building performant expand & collapse animations, Paul Lewis & Stephen McGruer

TL; DR

Используйте масштабирование при анимации. Избавиться от трансформации дочерних элементов во время анимации можно с помощью обратного масштабирования.

Ранее мы говорили о том, как реализовать производительные параллакс-эффекты и бесконечные скроллеры. В этой статье мы рассмотрим, как улучшить производительность анимации. Вы можете посмотреть пример в нашем репозитории Sample UI Elements.

Возьмем, к примеру, расширяющееся меню:

Некоторые варианты реализации более производительны, чем другие.

Плохо: анимация высоты и ширины контейнера

С помощью CSS мы может реализовать анимацию высоты и ширины контейнера:


.menu {
    overflow: hidden;
    width: 350px;
    height: 600px;
    transition: width 600ms ease-out, height 600ms ease-out;
}

.menu--collapsed {
    width: 200px;
    height: 60px;
}

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

Плохо: использование CSS свойств clip или clip-path

Альтернативой анимации ширины и высоты может быть использование свойства clip (устаревшее), чтобы оживить эффект разворачивания-сворачивания. Или, если хотите, вместо этого вы можете использовать свойство clip-path. Однако clip-path поддерживается хуже, чем clip. Но свойство clip устарело. Правильно. Но не отчаивайтесь, в любом случае, это не то решение, которое нам нужно!


.menu {
  position: absolute;
  clip: rect(0px 112px 175px 0px);
  transition: clip 600ms ease-out;
}

.menu--collapsed {
  clip: rect(0px 70px 34px 0px);
}

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

Хорошо: анимация масштабирования

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

Недостатком этого подхода, как и большинства других производительных решений, является то, что он требует немного настройки. Хотя оно того стоит!

Шаг 1: вычисление начального и конечного состояния

При реализации анимации масштабирования первым шагом является вычисление размеров свернутого и развернутого меню. Возможно, в некоторых ситуациях вы не сможете получить оба этих значения за один раз, вам нужно — скажем — переключить некоторые классы, чтобы получить различные состояния компонента. Если вам нужно это сделать, будьте осторожны: getBoundingClientRect() (или offsetWidth и offsetHeight) заставляет браузер запускать перерасчет и перерисовку, если стили изменились с момента последнего запуска.


function calculateCollapsedScale () {
  /* Заголовок меню может выступать как маркер для свернутого состояния */
  const collapsed = menuTitle.getBoundingClientRect();

  /* Меню полностью (заголовок плюс элементы) может выступать как маркер для развернутого состояния */
  const expanded = menu.getBoundingClientRect();
  return {
    x: collapsed.width / expanded.width,
    y: collapsed.height / expanded.height
  }
}

В случае с меню мы может сделать разумное предположение, что оно появляется в своем естественном размере (1, 1). Этот естественный размер соответствует развернутой версии, а анимировать нужно сворачивание.

Но подождите! Разумеется, это также приведет к масштабированию содержимого меню, не так ли? Ну, как вы можете видеть ниже, да.

Итак, что вы можете сделать по этому поводу? Ну, вы можете применить контр-преобразование к содержимому. Так, например, если контейнер уменьшен до 1/5 от его обычного размера, вы можете масштабировать содержимое до 5x, чтобы предотвратить его раздавливание. Здесь следует обратить внимание на две вещи:

  • Контр-преобразование также является операцией масштабирования. Это хорошо, потому что оно также может быть ускорено, как анимация контейнера. Вам может потребоваться убедиться, что анимированные элементы получают свой собственный слой, и для этого вы можете добавить элементу свойство will-change: transform или, если вам нужно поддерживать старые браузеры, backface-visiblity: hidden.
  • Контр-преобразование должно вычисляться покадрово. Это все немного осложняет. Если предположить что вы используете CSS анимацию и функцию замедления, то при контр-преобразовании необходимо использовать обратную к этой функции. Вычисление обратной к кривой — скажем — cubic-bezier(0, 0, 0.3, 1) не очевидно.

Может возникнуть соблазн реализовать анимацию с помощью JavaScript. Недостаток любой анимации на основе JavaScript заключается в том, что произойдет, если основной поток (где работает JavaScript) будет занят другой задачей. Короткий ответ: анимация будет тормозить или остановится совсем, что нехорошо с точки зрения UX.

Шаг 2: построение CSS анимации на лету

Решение, которое сначала может показаться странным, заключается в том, чтобы динамически создать анимацию с нашей собственной функцией замедления и вставить ее в страницу для использования в меню. (Большое спасибо инженеру Chrome Роберту Флэку за то, что он указал на это!)

Чтобы сделать анимацию, мы с шагом от 0 до 100 вычисляем, какие значения масштаба необходимы для элемента и его содержимого. Затем объединяем эти значения в строку, которая может быть добавлена на страницу в качестве элемента стиля. Добавление правила приведет к перерасчету стилей на странице, что является дополнительной работой, которую должен выполнить браузер. Но это будет происходить один раз при загрузке компонента.


function createKeyframeAnimation () {
  // Figure out the size of the element when collapsed.
  let {x, y} = calculateCollapsedScale();
  let animation = '';
  let inverseAnimation = '';

  for (let step = 0; step <= 100; step++) {
    // Remap the step value to an eased one.
    let easedStep = ease(step / 100);

    // Calculate the scale of the element.
    const xScale = x + (1 - x) * easedStep;
    const yScale = y + (1 - y) * easedStep;

    animation += `${step}% {
      transform: scale(${xScale}, ${yScale});
    }`;

    // And now the inverse for the contents.
    const invXScale = 1 / xScale;
    const invYScale = 1 / yScale;
    inverseAnimation += `${step}% {
      transform: scale(${invXScale}, ${invYScale});
    }`;

  }

  return `
  @keyframes menuAnimation {
    ${animation}
  }

  @keyframes menuContentsAnimation {
    ${inverseAnimation}
  }`;
}

Вас может удивить функция ease() внутри цикла for. В качестве этой функции можно использовать любой аналог функции замедления, принимающий значения от 0 до 1, например:


function ease (v, pow=4) {
    return 1 - Math.pow(1 - v, pow);
}

С помощью поиска Google вы можете посмотреть, как будет выглядеть эта функция. Удобно! Если вам нужны другие функции замедления, посмотрите Tween.js от Soledad Penadés, там их целая куча.

Шаг 3: включение CSS анимации

Когда анимация создана и добавлена на страницу с помощью JavaScript, последний шаг заключается в переключении классов, включающих анимацию.


.menu--expanded {
    animation-name: menuAnimation;
    animation-duration: 0.2s;
    animation-timing-function: linear;
}

.menu__contents--expanded {
    animation-name: menuContentsAnimation;
    animation-duration: 0.2s;
    animation-timing-function: linear;
}

Код выше запускает анимации, созданные на предыдущем шаге. Поскольку анимации уже рассчитаны, функция замедления должна быть установлена в linear, в противном случае анимация будет выглядеть очень странно!

Когда дело доходит до сворачивания элемента, есть два варианта: обновить CSS анимацию для запуска в обратном порядке. Это будет хорошо работать, но из-за функции замедления будет «ощущение», что анимация перевернута. Более подходящее решение — создать вторую пару анимаций для свертывания элемента. Они могут быть созданы точно так же, как и анимации разворачивания, но с измененными начальным и конечным значениями.


const xScale = 1 + (x - 1) * easedStep;
const yScale = 1 + (y - 1) * easedStep;

Более продвинутая версия: круговое меню

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

Принцип тот же, что и в предыдущем варианте, где мы масштабируем элемент и контр-масштабируем непосредственных детей. В данном случае свернутый элемент имеет border-radius 50%, что делает его круглым, и обернут в другой элемент с overflow: hidden, поэтому не видно, что круг расширяется за границы этого элемента-обертки.

Предупреждение, касающееся этого варианта: в Chrome во время анимации текст отображается размыто на экранах с низким DPI из-за ошибок округления масштаба и контрастности текста. Если вас интересуют подробности, вот ошибка, которую вы можете посмотреть.

Код для кругового эффекта расширения вы можете посмотреть в нашем репозитории.

Заключение

Есть способ производительной реализации анимации с использованием масштабирования. В идеальном мире было бы здорово, если бы анимация clip-path была ускоренной (вот баг, созданный Джейком Арчибальдом), но пока мы до него доберемся будьте осторожны с анимацией clip или clip-path, и по возможности избегайте анимации width или height.

Также было бы удобно использовать Web Animations для таких эффектов, потому что они имеют JavaScript API, но могут работать в отдельном потоке, если вы анимируете только свойства transform и opacity. К сожалению, поддержка Web Animations не очень хорошая, хотя здесь можно использовать прогрессивное улучшение.


if ('animate' in HTMLElement.prototype) {
  // Animate with Web Animations.
} else {
  // Fall back to generated CSS Animations or JS.
}

Код для этого эффекта вы можете посмотреть в нашем репозитории UI Element Samples.