Бегущая строка за четыре дня

24 Мар 2023

Бегущая строка выглядит почти оскорбительно простой задачей — положить элементы в ряд, сдвигать их влево или вправо и повторять всё сначала. Примерно так я и начал эксперимент с GSAP, ScrollTrigger и Custom Elements.

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

Сегодня опубликовал marquee-content@1.0.0. Разберу, что вошло в первую стабильную версию, какие решения сработали и где небольшой эксперимент уже успел обзавестись вполне взрослым тех. долгом.

Минимальная оболочка Custom Element

Я хотел, чтобы компонент подключался без отдельной разметки из служебных обёрток. Пользователь описывает содержимое, а библиотека занимается движением:

<marquee-content
  data-mc-duration="20"
  data-mc-direction="auto"
  role="marquee"
>
  <ul>
    <li>Primary</li>
    <li>Secondary</li>
    <li>Tertiary</li>
  </ul>
</marquee-content>

Компонент — автономный Custom Element:

export class MarqueeContent extends HTMLElement {
  constructor() {
    super();

    this.mm = gsap.matchMedia();
    this.tl = gsap.timeline();
    // Инициализация параметров и анимации
  }
}

customElements.get('marquee-content')
  || customElements.define('marquee-content', MarqueeContent);

Проверка через customElements.get() не даёт повторно зарегистрировать элемент при повторном подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM: стили страницы видят содержимое и могут управлять им напрямую.

У этого решения есть шероховатость. Текущая версия читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до connectedCallback(). Демо работает, потому что скрипт регистрирует элемент после разбора разметки, но при более раннем подключении инициализация может получить ещё пустой элемент.

Есть и интеграционный долг — демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет gsap зависимостью, но исходный модуль всё равно ждёт глобальные gsap и ScrollTrigger. Версия стабильная, способ подключения — не вполне.

Откуда берётся бесконечность

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

let requiredQuantity = (
  this.clientWidth / this.firstElementChild.clientWidth + 3
).toFixed(0);

for (let i = 1; i < requiredQuantity; i++) {
  const item = this.firstElementChild;
  const clone = item.cloneNode(true);
  item.parentNode.append(clone);
}

В более компактной записи расчёт выглядит так:

N = round(W / w + 3)

где W — ширина контейнера, w — ширина исходного блока, а N — итоговое количество блоков вместе с оригиналом.

Например, для контейнера шириной 960px и блока шириной 320px получается:

N = round(960 / 320 + 3) = 6

Три блока закрывают видимую ширину, ещё три дают избыточное покрытие во время циклического сдвига. Формула не ищет минимум, а сознательно создаёт лишние копии.

После клонирования GSAP создаёт одну временную шкалу анимации для всех дочерних элементов:

this.tl.to(this.children, {
  duration: this.duration,
  x: '-100%',
  ease: 'none',
  repeat: -1,
});

Каждая копия смещается на собственную ширину. Линейная функция плавности (ease: 'none') и бесконечное повторение (repeat: -1) создают непрерывный цикл, а одинаковые копии скрывают переход.

Направление без второй анимации

Для rtl и ltr не нужны две отдельные анимации. Достаточно менять знак timeScale:

this.tl
  .to(this.children, {
    duration: this.duration,
    x: '-100%',
    ease: 'none',
    repeat: -1,
  })
  .timeScale(this.dir === 'ltr' ? -1 : 1)
  .totalProgress(0.5);

Положительный масштаб времени проигрывает анимацию вперёд, отрицательный — назад. totalProgress(0.5) помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации, чтобы отрицательный timeScale не остановился сразу на абсолютном начале.

Третье значение, auto, связывает направление с прокруткой страницы. Обработчик сравнивает текущий pageYOffset с предыдущим и плавно переводит timeScale в 1 или -1:

const orientation = window.pageYOffset > currentScroll ? 1 : -1;

if (orientation !== scrollDirection) {
  gsap.to(this.tl, {
    timeScale: orientation,
    overwrite: true,
  });
}

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

Адаптивность через gsap.matchMedia()

Компонент поддерживает data-mc-min и data-mc-max по отдельности. Каждый из них превращается в медиазапрос, внутри которого создаются клоны и анимация. Если указать оба, min перезапишет запрос для max, поэтому полноценного диапазона из двух границ пока нет.

Для этого используется gsap.matchMedia() из GSAP 3.11. Метод запускает переданную функцию при совпадении запроса, а при выходе откатывает созданные GSAP-анимации и вызывает функцию очистки:

this.mm.add(this.breakpoint, () => {
  // Создание клонов и анимации

  return () => {
    removingClones();
  };
});

Это оказалось удобнее отдельного набора matchMedia().addEventListener() и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.

GSAP автоматически откатывает созданные внутри функции анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики scroll, resize и change не снимаются, поэтому очистка жизненного цикла остаётся неполной.

Неприятные сюрпризы

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

После пары экспериментов с уверенностью, что быстро решу проблему, последовала серия из тестовых коммитов.

Проверял несколько подходов:

  • Сравнивал новую ширину окна с предыдущей;
  • Пробовал отделять мобильные устройства через userAgent;
  • Слушал изменение ориентации;
  • Менял задержку debounce;
  • Полностью останавливал анимацию перед повторным клонированием.

В текущей версии обработчики подключаются через два медиазапроса:

this.mm.add('(any-pointer: coarse)', () => {
  const portrait = window.matchMedia('(orientation: portrait)');

  portrait.addEventListener('change', (event) => {
    if (!event.matches) {
      resetAmin();
    }
  });
});

this.mm.add('(any-pointer: fine)', () => {
  window.addEventListener(
    'resize',
    this.debounce(resetAmin, 250),
  );
});

(any-pointer: coarse) включает обработку смены ориентации, а (any-pointer: fine) — обычный resize с задержкой 250ms. Запросы не взаимоисключающие — на гибридном устройстве могут сработать оба.

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

API версии 1.0

В первый стабильный API вошли пять атрибутов:

  • data-mc-duration — длительность одного цикла, то есть сдвига на ширину блока, в секундах — по умолчанию 20;
  • data-mc-direction — rtl, ltr или auto;
  • data-mc-skew — наклон по оси Y;
  • data-mc-min — минимальная ширина для запуска;
  • data-mc-max — максимальная ширина для запуска.

data-mc-min и data-mc-max работают как альтернативы, а не как совместный диапазон.

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

Материалы