Бегущая строка за четыре дня: первая стабильная версия marquee-content

24 Мар 2023

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

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

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

Минимальная оболочка: 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

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

toFixed(0) возвращает строку, которую цикл затем неявно преобразует обратно в число. Код работает, но эта маленькая прогулка числа через строку не даёт ничего, кроме будущего вопроса «а зачем?». Здесь достаточно Math.round().

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

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

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

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

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

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

Положительный масштаб времени проигрывает timeline вперёд, отрицательный — назад. 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 по отдельности. Каждый из них превращается в media query, внутри которого создаются клоны и timeline. Если указать оба, min перезапишет запрос для max, поэтому полноценного диапазона из двух границ пока не получилось.

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

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

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

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

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

«Мобильные» мучения

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

Дальше были пробы, ошибки и тесты на реальных мобильных устройствах. Окончательная логика обработки указателей появилась позже. Некоторые ошибки очень убедительно говорят «починил», пока ещё раз не откроешь страницу на телефоне.

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

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

Проверка userAgent вроде бы отделяла известные мобильные платформы, но не определяла причину конкретного resize, а увеличение debounce лишь откладывало лишнюю пересборку.

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

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 с debounce в 250 ms. Запросы не взаимоисключающие: на гибридном устройстве могут сработать оба.

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

API версии 1.0

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

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

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

Итог первой версии

  1. Бесконечное движение — это прежде всего управление геометрией. Timeline занимает несколько строк, правильное число копий и пересборка после изменения размеров занимают всё остальное.
  2. resize сообщает о событии браузера, а не о намерении пользователя. На компьютере эти вещи часто совпадают. На мобильном устройстве resize может также возникать при движении адрес-бара или при повороте экрана.
  3. Custom Element — не только красивый тег. Он приносит жизненный цикл, повторное подключение к DOM и обязанность корректно освобождать ресурсы. В текущем варианте эта часть ещё слишком тесно связана с конструктором.

Желание посмотреть, насколько далеко можно уехать на одном repeat: -1, постепенно превращается в небольшой npm-пакет.

Источники